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):

VariantReturned closureRetained heap
A — nothing else references big() => 420.01 MB
B — an unused, never-called sibling references big() => 4238.17 MB
C — same as B, plus big = null before returning() => 420.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

TermDefinition
ReachabilityExistence of any reference path from a GC root; the sole criterion for retention
GC rootGlobal object, stack, document tree, timers, listeners, pending microtasks
Retained sizeMemory freed if this object became unreachable — the number that matters
Shallow sizeMemory of the object itself, excluding what it references
DominatorNode through which all paths to an object pass; removing it frees the subtree
Detached DOMNode removed from the document but still referenced from JS
detachednessPer-node field in modern V8 heap snapshots: 0 unknown, 1 attached, 2 detached
OilpanBlink'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
ContextV8's per-scope allocation holding captured variables shared by sibling closures
WeakMap / WeakSetCollections holding keys weakly; not enumerable, object keys only
WeakRef / FinalizationRegistryExplicit weak reference and post-collection callback; non-deterministic
Leak vs. cacheA 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

  1. "performance.memory shows 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's detachedness flag, or performance.measureUserAgentSpecificMemory().

  2. "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.

  3. "Setting the variable to null is cargo cult." Usually yes, and occasionally load-bearing — variant C above. The distinguishing question is whether a surviving closure shares the scope.

  4. "WeakMap makes memory concerns go away." It weakens the key, not the value. A value that references its own key creates a cycle a WeakMap cannot break. And WeakMap is not enumerable, so the leak you do have becomes harder to see in a dump.

  5. "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.

  6. "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.

  7. "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.memory doesn'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. removeEventListener requires remembering a second call and keeping a reference to the exact function; an AbortController signal 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; useEffect teardown 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.