Closures and Their Memory Cost
The Most Powerful Pattern in JavaScript
Closures power event handlers, data privacy, React hooks, module patterns, and half of functional programming in JavaScript. Most developers use them daily without realizing it. But few understand what a closure actually captures, how much memory it holds, and when that becomes a problem.
A closure is not magic. It's a function bundled together with a reference to its lexical environment. That reference is what makes closures powerful — and expensive.
Think of a closure as a function with a backpack. When a function is created inside another function, it stuffs a reference to the surrounding scope into its backpack. Wherever that function goes — passed as a callback, returned from a function, stored in a variable — it carries the backpack with it. As long as the function exists, everything in the backpack stays alive in memory.
What Is a Closure, Precisely?
A closure is created when a function is defined inside another function and references variables from the outer function's scope:
function createCounter() {
let count = 0; // This variable is "closed over"
return function increment() {
count++;
return count;
};
}
const counter = createCounter();
counter(); // 1
counter(); // 2
counter(); // 3
// count is not accessible from outside, but increment
// still has access to it through the closure
After createCounter() finishes, its local variable count would normally be garbage collected. But increment holds a reference to the scope containing count. As long as counter (which references increment) is reachable, count stays alive.
What Closures Actually Capture — The V8 Reality
Here's where most tutorials get it wrong. The specification says a closure captures the entire Lexical Environment. But what does V8 actually do?
function outer() {
const used = "I'm referenced";
const unused = new Array(1000000).fill("huge");
return function inner() {
console.log(used); // Only references 'used'
};
}
const fn = outer();
// Question: Is the giant 'unused' array kept in memory?
V8 performs scope analysis at parse time. It identifies which variables a closure actually references and creates a Context object containing only those variables. So in the example above, unused would be garbage collected.
But there's a catch:
function outer() {
const used = "I'm referenced";
const unused = new Array(1000000).fill("huge");
return function inner() {
console.log(used);
eval(""); // eval present — V8 can't analyze scope statically
};
}
const fn = outer();
// Now 'unused' IS kept alive — eval could reference anything
If eval() appears inside a closure (or if new Function() references outer variables), V8 cannot determine at parse time which variables might be accessed. It falls back to capturing the entire scope. This is one reason why eval is discouraged — it defeats scope optimization. The same applies to with statements and arguments in some cases.
The Shared Context Problem
Multiple closures from the same parent share a single Context object:
function setup() {
const small = "tiny";
const huge = new Array(1000000).fill("data");
function useSmall() { return small; }
function useHuge() { return huge; }
return useSmall; // We only keep useSmall
// But if useHuge was also returned/stored somewhere,
// both closures share the same Context
}
In V8, if any function in the same scope references a variable, that variable goes into the shared Context. So even if useSmall doesn't need huge, if another sibling closure (useHuge) references it and both closures are alive, huge stays in memory.
The Classic For-Loop Trap
The most famous closure gotcha:
for (var i = 0; i < 5; i++) {
setTimeout(function() {
console.log(i);
}, 100);
}
// Logs: 5 5 5 5 5 (not 0 1 2 3 4)
Why? var creates a single i scoped to the containing function. All five closures capture the same i. By the time the timeouts fire, the loop has finished and i is 5.
Three Fixes
// Fix 1: Use let (block scoping creates a new binding per iteration)
for (let i = 0; i < 5; i++) {
setTimeout(function() {
console.log(i); // 0, 1, 2, 3, 4
}, 100);
}
// Fix 2: IIFE (creates a new scope per iteration)
for (var i = 0; i < 5; i++) {
(function(j) {
setTimeout(function() {
console.log(j); // 0, 1, 2, 3, 4
}, 100);
})(i);
}
// Fix 3: bind (partially applies the current value)
for (var i = 0; i < 5; i++) {
setTimeout(function(j) {
console.log(j); // 0, 1, 2, 3, 4
}.bind(null, i), 100);
}
How let creates per-iteration bindings
The ECMAScript spec has special behavior for let in for loops (section 14.7.4.2). At the end of each iteration, the engine creates a new Lexical Environment for the next iteration and copies the current values of loop variables into it. So each iteration's closures capture a different environment with a different binding of i. This is not just syntactic sugar for an IIFE — it's a spec-mandated behavior specific to for loops with let/const.
Production Scenario: The Memory Leak
function setupHandler(element) {
const hugeData = fetchHugeDataset(); // 50MB of data
element.addEventListener("click", function handler() {
// Only uses element.id, but hugeData is in the closure's scope
console.log("Clicked:", element.id);
});
// hugeData is never referenced by handler,
// but if another closure in this scope references it,
// or if eval is used, it stays alive.
// Even after this function returns, if the event listener
// isn't removed, the closure (and potentially hugeData) persists.
}
The fix — minimize the closure's scope:
function setupHandler(element) {
const hugeData = fetchHugeDataset();
processData(hugeData); // Use it, then let it go
const elementId = element.id; // Extract only what's needed
element.addEventListener("click", function handler() {
console.log("Clicked:", elementId); // Tiny closure
});
}
| What developers do | What they should do |
|---|---|
| Assuming closures only capture referenced variables V8 creates one Context per scope level, shared by all closures in that scope | In V8, closures that share a scope share a Context — any variable referenced by ANY sibling closure is kept alive |
| Using var in for loops with async callbacks var creates one binding for the entire function. let creates one per iteration. | Use let — each iteration gets its own binding |
| Creating closures over large objects in event handlers The closure keeps references alive as long as the handler exists | Extract only the values you need into local variables before creating the closure |
| Forgetting to remove event listeners when components unmount Event listeners keep closures alive, which keep their captured scope alive | Store handler references and call removeEventListener in cleanup |
- 1A closure is a function plus a reference to its lexical environment — it carries its scope wherever it goes
- 2V8 creates a shared Context per scope level — all closures in the same scope share it
- 3eval() and with prevent V8's scope optimization, forcing full scope capture
- 4Use let in for loops to get per-iteration bindings — var creates one shared binding
- 5Minimize closure scope: extract only needed values into local variables before creating closures over large objects