Where Closure Variables Live: V8 Context Objects
You know that local variables “live on the stack”, yet heap snapshots are full of system / Context objects holding variables from functions that returned long ago. This guide from Stack vs Heap Memory Allocation in JavaScript, part of JavaScript Memory Fundamentals & Runtime Mechanics, explains how V8 decides which variables must outlive their stack frame, where it puts them, and what that means for memory.
| Symptom | Root Cause | Immediate Action | Measurable Impact |
|---|---|---|---|
Many system / Context objects in snapshots |
Each call of a function that creates closures allocates a context | Hoist closure creation out of hot, frequently called functions | Fewer context allocations per operation |
A large object retained via context of an unrelated callback |
Contexts are shared by all closures in a scope | Split scopes so long-lived closures capture only small values | Large object becomes collectable |
Deep retainer chains through previous context links |
Nested closures keep every enclosing context alive | Flatten nesting; pass values as arguments | Shorter chains, fewer retained scopes |
| Unexpected retention only while debugging | Debugger keeps full scopes alive for inspection | Take snapshots with the debugger resumed | Accurate retention picture |
| Retained size differs between dev and prod builds | Bundler changes scope structure | Compare source-mapped production snapshots | Diagnosis matches production behaviour |
Root Cause: Captured Variables Cannot Stay on the Stack
A JavaScript function’s local variables normally live in its stack frame (or in machine registers once optimised). When the function returns, the frame is popped and the variables disappear — no garbage collection involved, which is why primitives and objects differ in where they live mostly in terms of references rather than frames. A closure breaks that model: an inner function can run after the outer function has returned and must still see the outer function’s variables. Those variables therefore cannot live in the frame.
V8 solves this at parse time. When it parses a function, its scope analysis determines which of the function’s variables are referenced by any inner function. Those variables are marked as context-allocated: instead of stack slots, they get slots in a Context object that V8 allocates on the heap each time the function is called. Variables that no inner function references stay stack-allocated and cost nothing after return. Each closure created in that call stores a pointer to the context, and each context stores a pointer to its parent — the previous link — forming a chain that mirrors lexical nesting up to the script or module context.
Three consequences follow. First, allocation per call: a function that creates closures allocates a context object on every invocation, in addition to the closure objects themselves. In hot code — per event, per frame, per list item — that adds up. Second, sharing: all closures created in the same scope during the same call share one context, so a long-lived closure keeps alive every captured variable of that scope, including ones only a sibling closure used, as covered in shared closure context leaks. Third, chaining: a closure keeps its whole chain of enclosing contexts alive, so a small callback defined three levels deep can retain large variables captured at the outer levels.
Block scopes get their own contexts too: let and const inside a block that are captured by closures go into a block context, and each iteration of a for (let …) loop gets a fresh one. Direct eval or with forces every variable in scope into the context because V8 can no longer tell which names might be referenced.
Step-by-Step Fix
- Find contexts in a snapshot. In DevTools → Memory, take a heap snapshot and type
Contextinto the class filter. Sort by Retained Size. Verification: you seesystem / Contextentries; the largest ones retain significant memory. - Inspect a context’s slots. Expand a large context in the Containment or Summary view. Its properties are the captured variable names. Verification: you can see which variables it holds and which one is large.
- Identify the owning function and its closures. Follow the context’s retainers to the closures (
context in function X()) and open the function in Sources. Verification: you know every inner function created in that scope and which variables each references. - Reduce what is captured. Move large values out of scopes that create long-lived closures, pass values as arguments instead of capturing them, or copy the small piece you need into a local captured variable. Verification: after the change, the long-lived closure’s context no longer contains the large variable.
- Hoist closure creation from hot paths. If a function called thousands of times creates closures just to pass them along, define the function once at module or class level and pass data as parameters. Verification: an allocation profile of the hot path shows fewer
(closure)andsystem / Contextallocations. - Re-snapshot. Repeat the scenario. Verification: the context count and retained size for the function fall accordingly.
Command and Code Reference
Use case: see which variables are context-allocated. In a debug build of Node you can print scope info, but a practical check is DevTools’ Scope pane: pause inside the inner function and look at the Closure section — only captured variables appear there.
function makeCounter(label) {
const temp = expensiveSetup(); // not referenced by inner functions → stack only
let count = 0; // captured → context slot
return {
increment() { count++; }, // closure 1 → shares the context
read() { return `${label}: ${count}`; }, // closure 2 → captures label too
};
}
// Pause in read() → Scope pane → "Closure (makeCounter)" lists count and label, not temp
Use case: stop an inner callback from retaining an outer scope’s large data. Extract the callback so its chain does not include the data-holding context.
// Leaky: onDone's chain includes loadReport's context, which captured rows
function loadReport(id) {
const rows = fetchRowsSync(id); // 30 MB, captured below
renderTable(rows);
return function scheduleRetry() {
retryQueue.push(function onDone() { log(id); }); // chain: onDone → ... → rows
return rows.length; // this reference captures rows
};
}
// Fixed: the queued callback is created in a scope that only knows `id`
const makeOnDone = (id) => () => log(id); // tiny context: id only
function loadReportFixed(id) {
const rows = fetchRowsSync(id);
renderTable(rows);
const count = rows.length; // copy the small value you need
return function scheduleRetry() {
retryQueue.push(makeOnDone(id)); // no path to rows
return count;
};
}
Verification and Regression Prevention
After restructuring, confirm with a heap snapshot that the long-lived closure’s context contains only small values, and that the number of system / Context objects allocated per operation in the hot path fell (an allocation sampling profile of the same flow shows fewer context allocations). The large object you were chasing should now have no retainer path through any context.
To keep it that way, prefer small, top-level helper functions for callbacks stored in long-lived structures — queues, registries, timers, subscriptions — and keep data-heavy work in separate functions that return plain values. When reviewing code, look for closures created inside frequently called functions and for callbacks defined deep inside functions that also hold large locals; both patterns are easy to spot and cheap to fix before they become leaks.
Edge Cases and Gotchas
Arrow functions capture this through the context
Arrow functions do not have their own this; they capture the enclosing this through the context. An arrow function stored in a long-lived place therefore keeps the enclosing object (for example a component instance) alive, even if the body only uses one property.
Class field initialisers are closures
handler = () => this.save() in a class body is evaluated per instance in the constructor’s scope. Each instance gets its own closure and captures this, which is convenient and usually fine, but it means every instance allocates those closures up front.
Optimised code can drop dead captures
V8’s optimising compiler may avoid materialising some values when it can prove they are unused, but you cannot rely on this for memory: contexts are created by the interpreter before optimisation, and scope analysis already decided which variables are captured. Structure code so captures are minimal by construction.
Modules and scripts have contexts too
Top-level let, const and class declarations in a module live in the module’s context, which stays alive as long as the module is loaded. That is why module-level collections behave like permanent roots.
Frequently Asked Questions
Are closure variables stored on the stack or the heap?
On the heap. Variables captured by any inner function are allocated in a V8 Context object created for each call of the outer function. Uncaptured locals remain in the stack frame and disappear when the function returns.
Does every closure get its own context?
No. All closures created in the same scope during the same call share one context. Each closure additionally points to that context, and each context points to its parent context, forming a chain.
Does capturing a variable copy it?
No. The closure accesses the same slot in the shared context, which is why several closures see each other’s updates. For objects, the slot holds a reference, so capturing an object keeps that object alive for as long as the context is reachable.
Why do some variables appear in the DevTools Scope pane and others do not?
The Scope pane’s Closure sections show only context-allocated variables — the ones some inner function references. Variables that were never captured lived on the stack and are not part of any closure scope, so they are absent once the outer function has returned.
Related
- Stack vs Heap Memory Allocation in JavaScript — the parent topic
- How Async Functions and Generators Keep Frames on the Heap — the other way locals end up on the heap
- Closure Memory Leaks in Modern JavaScript — diagnosing closure retention in practice
- JavaScript Memory Fundamentals & Runtime Mechanics — the section overview