Concepts — Memory Model, Retention & Leaks
Phase 1 · Platform substrate · Specification areas §1 (closures, garbage collection, WeakRef, memory leaks, object shapes). Gates §11 (performance), §30 (large-scale UI), §41, and the "memory leak after hours" failure case study.
1. What is it
A JavaScript memory leak is not a failure to free memory. It is unintended reachability: an object you are done with is still reachable from a GC root, so the collector — correctly — keeps it. Every leak in this module is a correctness bug in a reference graph, not a resource-management bug.
Three things make this hard in a browser, and they are why this is a module rather than a footnote:
- You do not control collection. You control reachability. The only lever you have is severing references; when the collector acts is not your decision.
- The heap is not one heap. JavaScript objects live in V8's heap. DOM nodes live in Blink's C++ heap. The instrument most teams reach for reports only the first, so a large DOM leak can look like no leak at all.
- Retention is often created by code you did not write and never called. A sibling closure in the same scope, a framework's subscription registry, a module-level cache.
2. Why it matters
"The app gets slow after a few hours" is a leak until proven otherwise, and it is one of the twelve failure case studies in the specification. It is also the hardest class of bug to reproduce, because it requires time, and time is what test suites do not spend.
Leaks degrade before they crash. Long before an out-of-memory kill, a growing heap causes
longer and more frequent GC pauses — which appear as scheduling problems: long tasks, jank, INP
regressions with no obvious cause in the interaction path. This is the concrete link back to
fe-01: a team can spend a quarter optimising handlers when the actual cause is that major GC now
runs every few seconds.
The instrument selects the conclusion. Measured in this module: releasing 5,000 detached DOM
nodes freed 508 KB of DOM memory while moving the JS heap by 0.10 MB. A team checking
performance.memory, seeing it flat, and concluding "no leak" has been misled by their tool, not
by the data.
Single-page applications made this everyone's problem. A page that used to be discarded on every navigation now lives for a working day. Every route change, modal open, subscription and cache entry is an opportunity to retain something forever.
3. How it works
+==================================================================+
| REACHABILITY — the only thing that decides collection |
+==================================================================+
GC ROOTS
├── the global object (window)
├── the execution stack (locals of running functions)
├── the DOM tree reachable from document
├── active timers and their callbacks
├── registered event listeners (via their targets)
└── pending promises / microtask queue entries
|
v
anything reachable by ANY path is retained
|
+-----------+-----------+
| |
reachable unreachable
-> retained -> collectable (eventually)
+==================================================================+
| TWO HEAPS, TWO INSTRUMENTS |
+==================================================================+
V8 JS heap Blink C++ heap (Oilpan)
───────────────── ────────────────────────
objects, arrays, closures, DOM nodes, style data,
strings, Maps, wrappers layout objects
▲ ▲
│ │
Runtime.getHeapUsage NOT visible to it
performance.memory │
│ │
└─── heap snapshot ─────────────────┘
(includes native nodes +
a `detachedness` flag per node)
performance.measureUserAgentSpecificMemory() spans both,
but requires cross-origin isolation (COOP + COEP).
+==================================================================+
| THE FIVE RETAINER SHAPES |
+==================================================================+
1. DETACHED DOM node removed from document, JS ref survives
2. LISTENER long-lived target holds a closure over a dead component
3. TIMER setInterval callback closes over a dead scope
4. SIDE TABLE Map/Set/registry keyed by objects, never deleted
5. SHARED CONTEXT a sibling closure forces context allocation
Closures retain contexts, not variables
V8 allocates one Context per scope. If any closure created in that scope references a variable, the variable is context-allocated — and every sibling closure keeps the whole context alive, including closures that never mention it.
Measured (200 retained closures, each from a scope holding a 50,000-element array):
| Variant | Returned closure | Retained heap |
|---|---|---|
A — nothing else references big | () => 42 | 0.01 MB |
B — an unused, never-called sibling references big | () => 42 | 38.17 MB |
C — same as B, plus big = null before returning | () => 42 | 0.01 MB |
The returned closure is byte-for-byte identical in all three. What differs is only what else was declared in the same scope. This is why "my callback doesn't reference that variable" is not a defence, and why these leaks survive code review.
4. Core terminology
| Term | Definition |
|---|---|
| Reachability | Existence of any reference path from a GC root; the sole criterion for retention |
| GC root | Global object, stack, document tree, timers, listeners, pending microtasks |
| Retained size | Memory freed if this object became unreachable — the number that matters |
| Shallow size | Memory of the object itself, excluding what it references |
| Dominator | Node through which all paths to an object pass; removing it frees the subtree |
| Detached DOM | Node removed from the document but still referenced from JS |
detachedness | Per-node field in modern V8 heap snapshots: 0 unknown, 1 attached, 2 detached |
| Oilpan | Blink's C++ garbage collector, managing DOM and rendering objects |
| Minor GC (scavenger) | Frequent, cheap, collects the young generation |
| Major GC (mark-compact) | Infrequent, expensive, walks the whole heap; the source of long GC pauses |
| Context | V8's per-scope allocation holding captured variables shared by sibling closures |
WeakMap / WeakSet | Collections holding keys weakly; not enumerable, object keys only |
WeakRef / FinalizationRegistry | Explicit weak reference and post-collection callback; non-deterministic |
| Leak vs. cache | A cache has an eviction policy. Without one, it is a leak with better branding |
5. Mental models
You do not free memory; you sever references. Reframing every leak question as "what still points at this?" replaces an unanswerable question ("why isn't this freed?") with a mechanical one you can answer from a retainer path.
The retainer path is the bug report. In a heap snapshot, the object is not the finding — the path from a root to the object is. Fixing a leak means breaking exactly one edge on that path, and the edge you break determines whether the fix is local or architectural.
Ownership must be explicit, and it usually is not. Every subscription, timer, observer and
cache entry has an owner responsible for teardown. Leaks happen when ownership is implicit, so the
structural fix is to make teardown impossible to forget — an AbortController signal created at
registration time rather than a removeEventListener call someone must remember to write.
Growth shape, not growth. A single measurement proves nothing; heaps grow for many innocent reasons. Repeated identical cycles with a forced GC between them produce a slope and a linearity. Measured here: a clean workload had slope 0.001 MB/cycle with R² 0.742 — noise that looks correlated — while the leak had slope 1.836 MB/cycle with R² 1.000. Slope alone would have been ambiguous; slope plus linearity is not.
A cache without an eviction policy is a leak. The most common accidental cache is a
module-level Map keyed by objects. Measured: 38.34 MB retained and 2,000 immortal detached nodes
versus 0.09 MB and zero for the identical code using a WeakMap.
6. Common misconceptions
-
"
performance.memoryshows a flat line, so there's no leak." It reports the JS heap only. Measured: 5,000 detached nodes holding 508 KB of DOM memory moved the JS heap by 0.10 MB. Use a heap snapshot'sdetachednessflag, orperformance.measureUserAgentSpecificMemory(). -
"My closure doesn't reference it, so it can't retain it." False, and it is the subtlest result here — a 5,744× difference driven entirely by an unused sibling function.
-
"Setting the variable to
nullis cargo cult." Usually yes, and occasionally load-bearing — variant C above. The distinguishing question is whether a surviving closure shares the scope. -
"
WeakMapmakes memory concerns go away." It weakens the key, not the value. A value that references its own key creates a cycle aWeakMapcannot break. AndWeakMapis not enumerable, so the leak you do have becomes harder to see in a dump. -
"The GC will get to it eventually, so a small leak is fine." Unreachable objects are collected; reachable ones never are, no matter how long you wait. A leak does not shrink.
-
"Removing the element from the DOM frees it." Only if nothing else references it. Detached subtrees retain their entire subtree — one held reference to a leaf can retain thousands of ancestors and their event listeners.
-
"Leaks cause crashes." Eventually. Long before that they cause longer, more frequent GC pauses, which present as
fe-01-shaped problems: long tasks, jank, INP regressions with no cause in the interaction path.
7. Interview talking points
- "A leak is unintended reachability, not a failure to free. So the only question worth asking in front of a heap snapshot is 'what is the retainer path?' — the object is never the finding."
- "The first thing I check is whether the team measured the right heap. DOM nodes are in Blink's
C++ heap;
performance.memorydoesn't see them. We measured a case where releasing half a megabyte of detached DOM moved the JS heap by a tenth of that — flat graph, real leak." - "For leak detection I require repeated identical cycles with forced GC between them, and I report slope and R². We had a clean workload show R² of 0.74 — noise can look correlated, and reporting a slope alone costs someone a week."
- "The listener leak is worth fixing structurally rather than case by case.
removeEventListenerrequires remembering a second call and keeping a reference to the exact function; anAbortControllersignal created at registration ties teardown to the same line. That's making the invalid state hard to represent." - "Closures retain contexts, not variables. A sibling function that's never called can pin megabytes, which means the retaining code is often nowhere near the code that looks suspicious."
- "Leaks show up as scheduling problems first. If INP is regressing with no change in the interaction path, I want a heap trace before I let anyone optimise a handler."
8. Connections to other modules
fe-01-execution-model-scheduling— prerequisite, and the link is causal: GC pauses are long tasks. A leak diagnosed as a scheduling problem is the classic misdirection.fe-10/fe-11(hooks, memoization) — stale closures and effect cleanup are this module's retention rules wearing framework clothing;useEffectteardown is ownership.fe-16/fe-17(server state, races) — query caches are side tables keyed by objects. The eviction policy is what separates a cache from a leak.fe-30-large-scale-ui— virtualization creates and destroys thousands of nodes per second; a per-row listener or side-table entry becomes catastrophic rather than merely wasteful.fe-33-e2e— leaks need time to appear, which is why they escape unit tests. Long-running soak tests with heap assertions are the only automation that catches them.browser-framework-internals.md§8 (DOM Internals), §17 (V8 Integration) — cross when you need to know how DOM wrappers are kept alive across the two heaps.