Two inner functions are created in the same enclosing function: a short-lived one that reads a huge array, and a small one that is stored globally and never touches that array. Why can the huge array still be retained, and how do you fix it?
answer
- one context per scope, shared by all its closures
- any inner function's capture puts a variable in it
- the survivor roots the whole shared record
- sibling capture decides your retained set
- direct eval forces the entire scope to be kept
basics
~20 sBoth inner functions share one environment record for the enclosing scope, and that record holds every binding any inner function captures. The stored function therefore keeps the huge array reachable even though it never reads it.
solid answer
~50 sClosures do not each get a private copy of the scope: every function created in a given scope points at the *same* environment record, and engines populate that record with every variable captured by *any* inner function. So the small function you stored globally roots the shared record, and the shared record still holds the binding the other closure needed — the huge array stays reachable even though the survivor never mentions it. The fixes all break that sharing: null out the array binding after its last use, move the heavy consumer into its own function so the array is scoped there instead, or create the long-lived callback in a scope that does not contain the array at all. This is engine behaviour rather than a specified guarantee — ECMAScript says nothing about collection — but it is what V8 and other production engines do, and it is why the bug is hard to spot by reading only the surviving function.
code
javascript · 11 lineslet handler;
function setup() {
const huge = new Array(1e6).fill('x');
const report = () => huge.length; // puts `huge` in the shared context
console.log(report());
handler = () => 'ok'; // shares that context, so `huge` stays reachable
}
setup();
console.log(handler());go deeper
Take away the core surprise: several functions defined in the same function share one scope, so what one of them keeps alive can be kept alive for all of them.
Explain the mechanism in terms of one environment record per scope, populated with every binding captured by any inner function, and be able to point at the shared reference that roots it.
Show you can find this by reading: name the smell of a scope that both allocates something large and produces something long-lived, and prefer restructuring scopes over a fragile null-out that a refactor will delete.
Own the guidance that prevents it at scale: factory functions that hand out long-lived callbacks should not also be the place heavy data is loaded, and no design should depend on an optimizer trimming what it is not required to trim.
## The shape of the trap ```js let handler; function setup() { const huge = new Array(1e6).fill('x'); // used once, briefly const report = () => huge.length; // short-lived consumer report(); handler = () => 'ok'; // tiny, stored forever } setup(); // `handler` mentions nothing from setup - yet `huge` may still be alive ``` Read only `handler` and it looks like it retains nothing. Read the whole function and the mechanism appears. ## One record per scope, not one per closure Every function created inside `setup`'s body captures the same Environment Record — the record for that one invocation of `setup`. The specification models this directly: each function object's `[[Environment]]` slot points at the record that was current when the function literal was evaluated, and both literals were evaluated in the same place. Engines implement this with a heap-allocated *context* object per scope. The optimization they apply is at the granularity of the scope, not of the individual closure: a variable is placed in the context if **any** inner function captures it, and left in registers or on the stack otherwise. So `huge` goes into the context because `report` captures it — and `handler`, pointing at that same context, keeps the context alive, and with it the `huge` slot and the million-element array behind it. The conclusion that surprises people: **the retained set of a closure is determined by what all its siblings capture, not only by what it captures itself.** ## Why engines do it this way Giving each closure a private, precisely-trimmed environment would mean allocating and copying per closure and would break the shared-mutable-binding semantics the language requires — two closures over the same scope must see each other's writes. One shared context is both cheaper and semantically mandatory. Trimming is therefore possible only at the scope level: drop bindings no inner function reads at all. One more sharpening: a **direct `eval`** call in the scope defeats even that trimming, because the engine cannot know which identifiers the evaluated string will reference, so it must keep the whole scope. This is one of the concrete costs of direct `eval`, alongside the more familiar ones. ## The three fixes **Release the binding.** The blunt, local fix: ```js function setup() { let huge = new Array(1e6).fill('x'); const length = huge.length; huge = null; // slot survives, array does not handler = () => 'ok'; } ``` **Move the heavy work into its own function.** Then `huge` is a local of *that* call's scope, and the survivor created in `setup` never shares a record with it: ```js function computeLength() { const huge = new Array(1e6).fill('x'); return huge.length; } function setup() { const length = computeLength(); handler = () => 'ok'; } ``` This is the fix to prefer: it removes the coupling entirely rather than relying on a statement a future refactor can quietly delete. **Create the survivor elsewhere.** If the long-lived callback does not need anything from the heavy scope, define it outside that scope in the first place. ## Diagnosing it by reading Because the survivor's own body gives no clue, the reading discipline is: for any function that outlives its creating call, scan the *whole enclosing scope* and ask what the largest object bound there is. If a scope both allocates something big and produces something long-lived, that pairing is the smell — independent of which closure names what. ## What is guaranteed and what is not Nothing here is a language-level guarantee. ECMAScript defines scoping and reachability but says nothing about when memory is reclaimed, and an engine is free to be smarter (or dumber) about trimming a context. Treat the shared-context behaviour as the realistic model to design against — assume a sibling's capture can retain your data, and write code that does not depend on the optimizer being clever. Where you need a guarantee, restructure the scopes; there is no flag or annotation that promises trimming.
- Does a direct eval call somewhere in that scope change what the engine can drop?Yes, and it makes things strictly worse. Direct `eval` can reference any identifier visible at its call site, so the engine cannot prove which bindings are unused and must keep the full scope alive for closures over it. That retention cost sits alongside the usual reasons to avoid direct `eval`, and it applies even if the evaluated string touches nothing.
- Is this shared-context behaviour something the specification guarantees?The sharing is specified — closures created in one scope all point at the same Environment Record, which is required so they observe each other's writes. What is *not* specified is collection: ECMAScript says nothing about when memory is reclaimed or which unused bindings an engine may drop. So treat the retention as the behaviour to design against rather than as a rule you can quote.
- How would you catch this pattern in review, given the surviving function looks innocent?Read the enclosing scope, not the callback. The review question is: does this function both allocate something large and hand out a value that outlives the call? If so, require that the heavy allocation lives in its own function, or that the binding is cleared before the long-lived value escapes. The callback's own body is never enough evidence.
saying these in an interview costs you the question
- Assumes each closure gets its own trimmed copy of the scope
- Says the survivor cannot retain what it never references
- Claims the engine always drops unused captured variables
- Treats the retention as a specified language guarantee
- Thinks moving the code into a nested block solves it