Closure memory investigation
A one-line callback keeps a 40MB array alive for the lifetime of the page. Find out which line did it.
- Level
- RESEARCHER
- Read time
- 10 min
- Experiment
- Available
- Type
- INVESTIGATION
The question
Why does removing a listener sometimes free nothing, and why can a tiny function retain an enormous object?
Hypothesis
A closure retains its whole enclosing environment record, not only the variables you appear to use, so a reference chain from a GC root to that record keeps everything reachable.
Method
Laboratory available
The instrument for this investigation runs in the laboratory, where the controls, the live model and the observation log share one workstation.
Enter the laboratoryThe bench builds an object graph and marks what is reachable from a root. Attach and detach closures and watch which nodes survive a collection.
What we observed
function attach(node) { const hugeBuffer = new Array(1_000_000).fill(0); // ~8MB const id = node.id; node.addEventListener("click", () => { console.log(id); // only uses `id`… }); // …but may keep `hugeBuffer` reachable}Whether hugeBuffer survives depends on the engine's context allocation. V8 performs *context allocation analysis* and only stores variables the closure actually captures, so this specific case is usually fine. It stops being fine the moment any closure in the same scope references hugeBuffer, because all closures created in a scope share one context object.
Why it happens
function attach(node) { const huge = new Array(1_000_000).fill(0); node.addEventListener("click", () => console.log(node.id)); node.addEventListener("dblclick", () => console.log(huge.length)); // ^^^^ // Both listeners share one context object, so `huge` is retained // for as long as EITHER listener is alive.}| Path | What keeps it alive |
|---|---|
| Detached DOM node | A JS variable still references the node; its whole subtree stays |
| Event listener on a live node | The handler, its closure and everything the context captures |
| setInterval never cleared | The callback is a root for as long as the timer exists |
| Module-scope cache / Map | Lives as long as the module, use WeakMap when keys are objects |
| Promise that never settles | Its reactions and their closures |
| Console-logged object in devtools | Retained by the console itself, a classic false positive |
Why can't the engine just free what I stopped using?
Because 'stopped using' is not decidable, only 'unreachable' is.
Further research
Modern V8 uses a generational collector: a small, fast scavenger for the young generation, and a concurrent mark-and-sweep/compact cycle for the old generation. Short-lived allocations are genuinely cheap, the expensive pattern is *surviving* the scavenge, which promotes an object to old space and makes it the major collector's problem.
References
- 01MDNMemory management
- 02V8 blogTrash talk: the Orinoco garbage collector
- 03Chrome DevToolsFix memory problems