How it works
The spec model is simple: OrdinaryFunctionCreate stores the current Environment Record in [[Environment]], and each record links outward through [[OuterEnv]]. Read literally, a closure keeps every binding in every enclosing scope. The spec also says Environment Records need not correspond to any implementation artefact, so engines are free to keep only what can still be observed.
V8 does this at parse time. Function activations live on the machine stack for speed, but an inner function can outlive the activation that created it. So variables referenced from inner functions are allocated on the heap, in a Context. When V8 evaluates a function literal, it creates a closure (a JSFunction) that points to the function's shared code (SharedFunctionInfo) and to the current context. The scope analysis records, per scope, how many context slots it needs; at run time, entering that scope creates one Context with those slots, linked to its parent through a previous pointer. A function call gets a function context only when the function has context-allocated variables; blocks with let/const, catch clauses, and modules have their own context types.
Because lazily compiled functions are only pre-parsed at first, the preparser still has to track which outer variables each inner function references — otherwise V8 couldn't decide allocation for the outer function correctly.
function track(items) {
const count = items.length; // used only by `track` itself → not in the context
const index = new Map(items.map((item) => [item.id, item])); // read by `find` → context
let lookups = 0; // read by `stats` → context
const find = (id) => (lookups++, index.get(id));
const stats = () => lookups;
console.log(`tracking ${count}`);
return stats; // `stats` holds the whole context: `index` and `lookups`
}
Only stats escapes, and it only reads lookups, but it points to the same Context as find, so the Map stays alive as long as stats does.
Edge cases
- Direct
eval. A closure containing a direct eval can reference any binding by name at run time, so nothing in the enclosing scopes can be dropped. In Node 24, a closure that only calls eval('1') retains a large local that nothing mentions. Indirect eval ((0, eval)('…')) runs in the global scope and doesn't have this effect.
- Block scopes are separate contexts. A closure created outside a block doesn't hold the block's
Context, so moving the capturing code into a block is a real fix.
- Releasing the slot. Assigning
null to a captured let clears it for every closure sharing the Context.
- Measuring.
WeakRef targets are kept alive until the end of the current job (AddToKeptObjects), so a test must yield before forcing GC. Treat gc() results as a diagnostic, not a guarantee.
// Run with: node --expose-gc fixes.mjs
const settle = () => new Promise((resolve) => setTimeout(resolve));
function scoped() {
const handler = () => 'tiny'; // created outside the block that captures `big`
let ref;
{
const big = new Array(1e6).fill(0);
ref = new WeakRef(big);
const debug = () => big.length; // lives in the block's own context
debug();
}
return { handler, ref };
}
function released() {
let big = new Array(1e6).fill(0);
const ref = new WeakRef(big);
const debug = () => big.length;
debug();
big = null; // clears the slot that every closure from this call shares
return { handler: () => 'tiny', ref };
}
for (const make of [scoped, released]) {
const { handler, ref } = make();
await settle();
globalThis.gc();
console.log(make.name, handler(), ref.deref() !== undefined);
}
// → scoped tiny false
// → released tiny false
Real-world scenario
Sibling sharing can turn one retained object into an unbounded chain. Here each refresh creates a closure that reads previous, and a long-lived describe method that doesn't. Both share the call's Context, so each new entry's describe keeps the previous entry alive, which keeps the one before it, and so on:
// Run with: node --expose-gc chain.mjs
const settle = () => new Promise((resolve) => setTimeout(resolve));
let current = null;
let firstRef;
function refresh() {
const previous = current;
const wasReady = () => previous !== null; // only `wasReady` reads `previous`…
current = {
data: new Array(1e6).fill(0),
describe: () => 'cache entry', // …but `describe` shares its context
};
firstRef ??= new WeakRef(current);
}
for (let i = 0; i < 5; i++) refresh();
await settle();
globalThis.gc();
console.log(firstRef.deref() !== undefined); // → true
All five arrays stay reachable from current. Reading the value eagerly — const wasReady = current !== null — removes the captured binding, and the same test prints false.
Trade-offs
V8's shared per-scope Context is the cheap, correct design for writable bindings: one allocation per scope entry, variables at fixed slots, and each closure a small JSFunction pointing at shared code and a context. The price is coarse retention, which you control by structure rather than by the engine: keep long-lived callbacks small and in scopes that don't capture large data, release captured references when done, and remove listeners and timers. Rewriting closures as classes only helps if it also removes the long-lived reference; an instance stored in a listener retains its fields just the same.