Skip to content
JavaScript Lab · LAB-03closures / environment record

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 laboratory

The 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

example.jsjs
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}
Both handlers look harmless. One is not.

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

example.jsjs
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.}
The shared-context trap
PathWhat keeps it alive
Detached DOM nodeA JS variable still references the node; its whole subtree stays
Event listener on a live nodeThe handler, its closure and everything the context captures
setInterval never clearedThe callback is a root for as long as the timer exists
Module-scope cache / MapLives as long as the module, use WeakMap when keys are objects
Promise that never settlesIts reactions and their closures
Console-logged object in devtoolsRetained by the console itself, a classic false positive
Common retention paths in a browser app
The Why MachineDEPTH 1 / 4

Why can't the engine just free what I stopped using?

  1. 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

  1. 01MDNMemory management
  2. 02V8 blogTrash talk: the Orinoco garbage collector
  3. 03Chrome DevToolsFix memory problems