JavaScript Closures — Silent Memory Leak from DOM Nodes
RSS memory grew for 6h until OOMKill — closures captured jsdom DOM nodes.
20+ years shipping production code across the stack, with years spent interviewing engineers. Written from production experience, not tutorials.
- Closures bundle a function with its lexical environment — the variables in scope when the function was defined, not invoked
- The [[Environment]] internal slot on every function points to the scope chain at definition time
- The loop bug: var creates one binding per loop, so all callbacks share the same
i— useletfor per-iteration bindings - Memory cost: closures keep the entire context alive — one large variable in a shared scope prevents GC of everything else
- Production insight: stale closures in React hooks cause silent state bugs — fix with functional updates or useRef
Imagine you work at a coffee shop and you're given a notepad with the day's special price written on it. Even after you leave the shop and go home, you still have that notepad — you can read the price anytime. A JavaScript closure is exactly that: a function that 'takes its notepad home' — it remembers the variables from where it was created, even after that original context is long gone. The function and its remembered environment are inseparable.
Closures are the single most interrogated concept in JavaScript interviews — and for good reason. They're not just a language quirk; they're the engine behind module patterns, memoization, event handlers, React hooks, and virtually every callback-heavy system you'll write in production. If you don't own closures cold, you'll struggle to reason about async bugs, memory leaks, and stateful logic at scale.
The problem closures solve is deceptively simple: how does a function retain access to variables that were defined in a scope that has already finished executing? In most mental models of code, once a function returns, its local variables evaporate. Closures break that assumption in the most useful way possible — they keep a live reference to the surrounding lexical environment, not a snapshot, not a copy.
By the end of this article you'll be able to explain the V8-level mechanics of how closures are stored, identify the three classic closure interview traps (the loop bug, the memory leak, and the stale reference), write module patterns and memoization from scratch, and answer the follow-up questions that trip up even experienced devs. Let's build this from the engine up.
What JavaScript Closures Actually Capture — and Why DOM Nodes Leak
A closure is a function bundled with its lexical environment — the variables in scope at definition time. The core mechanic: inner functions retain references to outer variables even after the outer function returns. This isn't magic; it's how the engine preserves the scope chain for execution. Every closure holds a live reference to the entire outer scope, not a snapshot. That means if an outer variable is a DOM node, the closure keeps that node alive in memory. The garbage collector cannot reclaim it until every closure referencing that scope is itself unreachable. In practice, this turns event handlers, callbacks, and timers into accidental memory anchors. A single closure attached to a detached DOM subtree prevents the entire subtree from being freed. The cost is not theoretical — it's measurable heap growth in long-lived pages. Use closures intentionally: capture only what you need, nullify references when the component unmounts, and avoid capturing large objects or DOM nodes in long-lived closures.
How the JavaScript Engine Actually Creates a Closure
When the JS engine parses a function, it records the function's lexical environment — the scope chain that was active at the point of definition, not the point of invocation. This is baked into the function object itself as an internal [[Environment]] slot. You can't read it directly, but it's always there.
When the outer function returns, its Execution Context is popped off the call stack. Normally the local variables would be garbage-collected. But if any inner function holds a reference to those variables through its [[Environment]] slot, the garbage collector sees them as still reachable — so it keeps them alive in a structure called a closure record (or context object in V8 terms).
This is why closures aren't 'magic' — they're a predictable consequence of lexical scoping plus garbage collection. The engine asks one question: 'Is anyone still pointing at this variable?' If yes, it stays alive.
Critically, the closure captures variables by reference, not by value. If the outer variable mutates after the closure is created, the closure sees the new value. This is the root cause of the infamous loop-closure bug and every stale-value head-scratcher you'll encounter in production.
The Classic Loop-Closure Bug — and Three Ways to Fix It
This is the most-asked closure question in JavaScript interviews, bar none. It seems simple, and that's exactly what makes it dangerous. The bug happens because var is function-scoped. Every iteration of the loop doesn't create a new variable — it mutates the same memory location. By the time the callbacks fire, the loop has completed and the variable holds its final value.
There are three idiomatic fixes: using let (block-scoped), using an IIFE to create a new scope per iteration, or using .forEach. Understanding why let works is key: the ES6 spec mandates that a let variable in a for loop header is re-bound for every iteration of the loop.
let fix works because the ECMAScript spec mandates that let in a for loop header creates a new binding for each iteration — it's not just block-scoping the variable, it's actively re-binding it. If you use let in a while loop without manually reassigning, you don't get this guarantee.let is zero-overhead — the spec creates new bindings per iteration with negligible memory cost.let in for loops that create callbacks, not var.for loop creates a new binding each iteration — this fixes the closure bug.Closures in Production — Module Pattern, Memoization & Memory Pitfalls
Closures allow for powerful patterns like the Module Pattern (private state) and Memoization (caching). However, they come with a memory cost. Because a closure keeps its entire [[Environment]] alive, a large object captured in a closure will never be garbage-collected as long as the closure exists.
In V8, if multiple inner functions are defined in the same scope, they share a single context object. This means if one inner function captures a large array, that array stays in memory even for other functions that don't use it, provided they were created in the same environment.
(context) entries that contain arrays bigger than expected.Stale Closures in React Hooks — The Modern Production Gotcha
Stale closures occur when a function 'remembers' a variable from an old render cycle. In React, because useEffect and useCallback create closures, if the dependency array is incorrect, the closure holds onto old state values.
To fix stale closures, developers use functional updates in setState or the useRef pattern, which provides a stable reference that always points to the latest value without requiring the closure to be re-created.
eslint-plugin-react-hooks enforces exhaustive dependency arrays precisely to catch stale closures at lint time. Disabling that rule with // eslint-disable-next-line is almost always the wrong call.setCount(prev => prev + 1) — no closure dependency needed.Closures in Event Handlers — The Hidden Memory Leak Pattern
Event listeners attached to DOM nodes (or Node.js EventEmitter objects) create closures that capture the surrounding scope. If the listener is never removed, the entire captured scope stays in memory — including the DOM node itself, preventing it from being garbage-collected when it's removed from the document.
This is the single most common closure-related memory leak in browser apps. The fix is simple: always pair every addEventListener with a corresponding removeEventListener in the cleanup phase. For React, use the cleanup function returned from useEffect. For plain JS, use addEventListener's { once: true } option for one-shot events.
Closures Capture Variables, Not Values — Here's Why That Bites You
Most devs think closures capture a snapshot of a variable's value. That's wrong. Closures capture the variable itself — the reference. This is why the classic loop bug happens, and why seemingly innocent code can produce baffling output. When a closure runs, it reads the current value of the captured variable, not the value it had when the closure was created. If the variable is reassigned between creation and invocation, the closure sees the new value. This is the root cause of stale closure bugs in event handlers, timers, and async callbacks. Understanding that closures capture variable bindings (not values) is the single most important mental model shift you can make. It explains why let fixes the loop bug — it creates a new binding for each iteration — while var doesn't.
let or an IIFE to capture the current iteration's value.The Closure Chain — How Nested Closures Create a Scope Ladder
A common interview curveball is nested closures. Each inner closure doesn't just capture the immediate outer function's variables — it captures the entire scope chain. This creates a ladder of accessible variables from the innermost function all the way out to the global scope. The key insight: closures don't copy variables up the chain; they maintain a reference to the entire lexical environment. This means modifying a variable in an outer scope from a deeply nested closure affects all closures at that level. It also means memory can leak if any closure in the chain outlives its parent scope. Interviewers love asking about this because it tests whether you understand scope chaining, not just single-level closure mechanics.
inner keeps a reference to both middle's environment (with y) and outer's environment (with x). If inner lives forever (e.g., in a callback), all three scopes stay in memory.Closures in Event Handlers — The Hidden Memory Leak Pattern
The most common production leak with closures happens in event handlers. Attach a closure-based handler to a DOM element, and if the handler captures a reference to the element itself (via this or a variable), you've created a cyclic reference. The element holds a reference to the handler (via the event system), and the closure holds a reference back to the element (via the captured scope). Modern garbage collectors can handle simple cycles, but if that closure also captures a large data structure — like a massive state object or a DOM subtree — the element and all its captured data stay alive until the handler is explicitly removed. This is why single-page apps leak memory like sieves. The fix: always use addEventListener with a named function you can removeEventListener on, or use AbortController in modern browsers.
A Silent Memory Leak: Closures Holding DOM Nodes
window.addEventListener('resize', handler), where handler was a closure that captured the entire data variable from the outer scope. The listener was never removed, so the window object and all its DOM tree stayed reachable even after the response was sent. The closure's [[Environment]] kept data alive, and data kept references to the DOM.window.removeEventListener. Alternatively, use { once: true } for one-shot events, or structure the code so the closure doesn't capture large variables directly (pass only necessary data). In the incident, they switched to using a WeakMap to associate data with the window object and cleaned up in a finally block.- Every DOM event listener that captures data in a closure must be explicitly removed when no longer needed.
- Use
once: truefor single-use event handlers to avoid the removal overhead. - Prefer passing minimal data into callbacks rather than capturing large objects from the outer scope.
- Memory profiles are your friend — heap snapshots showing many detached DOM trees point directly at closure retention.
var to let in the loop header, or wrap the callback in an IIFE that captures the current iteration value.setCount(prev => prev + 1), or refactor with useRef to hold the latest value and reference that in the closure.--inspect). Look for closures under (closure) in the tree. Check for event listeners that are never removed or large arrays captured in closure scope that shouldn't be.heap snapshot (DevTools Memory tab) — filter by '(closure)' and look for retained objects with unexpected large arrays.`getEventListeners(window)` in console (Chrome-only) to list all event listeners. Check for any that shouldn't exist.{ once: true } if the event should fire only once.Key takeaways
Common mistakes to avoid
3 patternsThinking closures capture VALUES
Creating memory leaks with unremoved event listeners
removeEventListener in component cleanup. Use { once: true } for one-shot events. In React, use the useEffect cleanup function.Suppressing React's exhaustive-deps lint rule
setCount(prev => prev + 1)) to avoid capturing the state value.Interview Questions on This Topic
Explain the 'Diamond of Death' equivalent in JS Closures: if two functions share a parent scope, can they access each other's private variables? Why/Why not?
Frequently Asked Questions
20+ years shipping production code across the stack, with years spent interviewing engineers. Written from production experience, not tutorials.
That's JavaScript Interview. Mark it forged?
6 min read · try the examples if you haven't