Shared Closure Context: Why One Closure Retains Another’s Variables
A tiny callback that only reads an ID is keeping a 40 MB dataset alive, even though the callback never touches the dataset — the cause is V8’s shared closure context, and this guide from Closure Memory Leaks in Modern JavaScript, in the Browser DevTools & Performance Profiling Workflows section, shows how to recognise it in a heap snapshot and restructure the code so each closure holds only what it needs.
| Symptom | Root Cause | Immediate Action | Measurable Impact |
|---|---|---|---|
Large object retained via context in function() of a callback that never uses it |
All closures in a scope share one context; another closure used the variable | Inspect the context’s variables in the snapshot | Identifies the co-located closure that captured it |
| Leak appears after adding an unrelated helper function | The new helper references a big variable in the same scope | Move the helper or the big variable into a separate scope | Long-lived callback stops retaining the big variable |
Setting a variable to null “fixes” the leak |
The shared context slot is cleared, releasing the value | Keep the fix, or better, restructure scopes | Retained size drops by the variable’s size |
| Leak only in production builds | Minifier inlines or merges functions into one scope | Check source-mapped snapshot; restructure explicitly | Behaviour no longer depends on bundler choices |
Root Cause: One Context Object per Scope, Not per Closure
When a JavaScript function creates inner functions, V8 has to keep the variables those inner functions reference alive after the outer function returns. It does this with a context object: a heap-allocated record holding each captured variable. The crucial detail is that V8 allocates one context per scope, shared by every closure created in that scope. The parser determines which variables are referenced by any inner function and moves exactly those variables into the context; variables that no inner function references stay on the stack and die with the call.
So if a scope contains two closures — onTick, which uses only id, and summarise, which uses bigData — both id and bigData go into the same context, and both closures point to it. If summarise is called once and thrown away while onTick is registered with setInterval for the lifetime of the page, onTick keeps the whole context alive, and with it bigData. From the language’s point of view onTick cannot read bigData; from the garbage collector’s point of view it is reachable through the context, so it stays.
In a heap snapshot this shows up as a retainer path through context in function onTick() leading to a system / Context object whose properties include bigData. Engineers often read that path, look at onTick’s source, see no mention of bigData, and conclude the snapshot is wrong. It is not; the context is shared. The broader closure retention walkthrough covers reading closure retainers generally; this guide is about the specific surprise that the retained variable belongs to a sibling closure.
eval and with make things worse: if a scope contains direct eval, V8 must assume any variable can be referenced, so every variable in the scope goes into the context. The same applies when DevTools’ debugger is paused in a scope — which is why a leak sometimes appears only while you are debugging.
Step-by-Step Fix
- Capture the retainer path. In DevTools → Memory, take a snapshot after the leak has built up, find the large retained object (sort Summary by Retained Size), and select it. Verification: the Retainers pane shows
bigData in system / Contextabovecontext in function onTick(). - Inspect the context’s contents. Click the
system / Contextentry in the retainer tree (or find it in the Containment view) and expand it. Verification: you see every captured variable of the scope, including ones the retaining closure never mentions. - List the closures created in that scope. Open the outer function in Sources and list every inner function, arrow function and callback it creates. Verification: at least one other closure references the large variable — that is why it is in the shared context.
- Split the scopes. Move the short-lived closure and the large variable into a separate function, or pass the large value as an argument instead of capturing it, so the long-lived closure’s scope never contains it. Verification: in the source, the long-lived closure’s enclosing scope no longer declares the large variable.
- Or release the slot explicitly. When restructuring is impractical, set the large variable to
nullonce it is no longer needed. Verification: the value can be collected even while the long-lived closure survives. - Re-snapshot. Repeat the scenario and take a new snapshot. Verification: the context retained by
onTickcontains only small values, and the large object no longer appears.
Command and Code Reference
Use case: the leaking shape. A widget setup function creates a long-lived timer callback and a one-off summary function in the same scope.
// Leaky: onTick and summarise share startWidget's context
function startWidget(id) {
const bigData = loadHugeDataset(); // ~40 MB array
const summarise = () => bigData.length; // forces bigData into the context
renderSummary(summarise()); // used once
setInterval(function onTick() { // lives for the whole session
pollStatus(id); // only needs `id`...
}, 5000); // ...but keeps the context (and bigData)
}
Use case: split the scope so the long-lived closure captures only what it uses. Moving the data work into its own function gives it its own context, which dies when the call returns.
// Fixed: bigData lives in a separate scope that nothing long-lived references
function summariseDataset() {
const bigData = loadHugeDataset();
return bigData.length; // context released when this returns
}
function startWidgetFixed(id) {
renderSummary(summariseDataset());
const timer = setInterval(function onTick() {
pollStatus(id); // context holds only `id`
}, 5000);
return () => clearInterval(timer); // and the timer can be stopped
}
Use case: a quick, explicit release when restructuring is not possible. Clearing the variable empties the context slot.
function startWidgetMinimal(id) {
let bigData = loadHugeDataset();
renderSummary(bigData.length);
bigData = null; // slot cleared; array can be collected
setInterval(() => pollStatus(id), 5000);
}
Verification and Regression Prevention
Verify by repeating the scenario with the same duration and taking a snapshot: the long-lived closure’s context should list only small values, and the large object should be absent from the Summary view or have a retained size near zero. Check that nothing else still references the data — occasionally the shared context was only one of two paths.
To prevent recurrence, adopt a simple code-review rule: functions that register long-lived callbacks (timers, global listeners, subscriptions) should not also declare large local data. Keep them thin, and do data work in separate helpers that return plain results. Include a leak test for widgets that register timers, using the three-snapshot technique or an automated equivalent, and assert that mounting and unmounting the widget ten times leaves no growing constructor. Where the timer itself outlives the widget, the separate problem of timer and interval leaks needs fixing too.
Edge Cases and Gotchas
Block scopes get their own contexts
let and const declared inside a block (if, for, { }) that are captured by closures live in a block context, separate from the function’s context. Moving a large variable into a block that only the short-lived closure uses is often the smallest possible fix — the long-lived closure outside the block never references the block context.
Loop closures capture per-iteration contexts
In for (let i = ...) loops, each iteration gets a fresh binding, and closures created in the loop body capture that iteration’s context. If each iteration also declares a large temporary that one of its closures references, and those closures are stored, you keep one large temporary per iteration alive. Keep large temporaries out of loop bodies that create stored callbacks.
Transpilers and minifiers can merge scopes
Bundlers sometimes hoist functions, inline modules or wrap code in a single function scope, which can put variables that were in separate modules into one shared context. If a leak appears only in production, compare a source-mapped production snapshot with a development one and make scope boundaries explicit in the source rather than relying on module boundaries.
Debugger pauses keep everything
While DevTools is paused at a breakpoint, the engine keeps all variables in scope reachable so you can inspect them. Snapshots taken while paused, or in a session with many breakpoints hit, can therefore show retention that does not exist in normal execution. Take leak snapshots with the debugger resumed.
Frequently Asked Questions
Does V8 still share closure contexts in current versions?
Yes. Context sharing is a deliberate design choice: one context per scope is cheaper to create and access than a separate record per closure. V8 only captures variables that some inner function references, but all closures in the scope share that one context.
Why does my leak disappear when I remove an unrelated function?
Because that function was the one referencing the large variable. With it gone, the variable is no longer captured by any closure, so it stays on the stack and dies when the outer function returns, and the long-lived closure’s context no longer contains it.
Do arrow functions and class methods behave differently?
Arrow functions are closures like any other and share their enclosing scope’s context. Class methods defined on the prototype do not capture local variables, but class field arrow functions (handle = () => {...}) are closures created in the constructor’s scope and share its context.
Related
- Closure Memory Leaks in Modern JavaScript — the parent topic
- Where Closure Variables Live: V8 Context Objects — how V8 decides which variables go into a context
- How to Find the Closure Retaining an Object in DevTools — reading closure retainers step by step
- Browser DevTools & Performance Profiling Workflows — the section overview