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.


0. Orientation

The 60-second version

A JavaScript memory leak is not a failure to free memory. The garbage collector is correct; it frees everything unreachable, always. A leak is unintended reachability — an object you are finished with is still reachable from a GC root, so the collector keeps it, exactly as designed.

That reframe does the whole module's work. It converts an unanswerable question — "why isn't this being freed?" — into a mechanical one you can answer from a heap snapshot: "what still points at this?" The answer is a retainer path, and the retainer path is the bug report.

Three facts make this hard enough to deserve a module:

  1. You do not control collection. You control reachability. The only lever is severing references. When the collector runs is not your decision and cannot be made your decision.
  2. The heap is not one heap. JS objects live in V8; 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.
  3. Retention is often created by code you did not write and never called. Measured here: an unused, never-invoked sibling closure retained 38 MB where the correct version retained 0.01 MB — a 5,744× difference, from a function nobody calls.

What you should be able to do after this module

CapabilityEvidence you have it
Find a leak from a snapshotProduce the retainer path, not the object, and name which edge to cut
Pick the right instrumentKnow before measuring whether the suspected leak is JS-heap or DOM-heap shaped
Make a defensible claimReport slope and R² over identical GC-separated cycles, with a control
Fix at the right altitudeDistinguish a one-line detached-node fix from a cache-ownership design question
Connect symptoms to causeRecognise a leak presenting as an INP regression with no interaction-path change

The map

  REACHABILITY (§3.1) ──► the only rule
        │
        ├─► FIVE RETAINER SHAPES (§3.3) ──► each has a fix ALTITUDE (§6.1)
        │        detached DOM · listener · timer · side table · shared context
        │
        ├─► TWO HEAPS (§3.2) ──────────► which instrument tells the truth (§6.2)
        │
        └─► DETECTION AS STATISTICS (§6.3) ──► slope + R² + control + magnitude

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.

Why the platform is built this way

JavaScript has no free(), and that is not an omission. A manual-free language lets you create dangling pointers — references to memory that has been released. In a language where objects are passed by reference everywhere and closures capture scopes implicitly, dangling pointers would be routine and catastrophic. Tracing garbage collection buys memory safety: it is impossible to observe a freed object, because an observable object is by definition not freed.

The price you pay is exactly the module's subject:

What GC guaranteesWhat it therefore cannot doWhich produces
Never frees something reachableDistinguish "reachable" from "wanted"Leaks, as unintended reachability
Frees everything unreachable, eventuallyTell you whenNon-deterministic teardown; untestable freeing
Traces the whole object graphDo it for freeGC pauses that present as scheduling problems

The collector cannot read your intent. It has one input — the reference graph — and that graph is the only thing you can change. Every fix in this module is a graph edit.

Two collectors, not one

The browser runs two independent garbage collectors over two heaps, and they must cooperate:

  • V8's GC manages the JavaScript heap: objects, arrays, closures, strings, Maps, and the wrapper objects that represent DOM nodes to JavaScript.
  • Oilpan manages Blink's C++ heap: the actual DOM nodes, style data, layout objects.

A DOM node you can touch from JavaScript is really two allocations — a C++ node in Oilpan and a JS wrapper in V8 — joined by a cross-heap edge. Keeping both alive when either is referenced requires the two collectors to trace across the boundary, and it is why a single held reference to one <div> can retain a whole detached subtree in a heap your JS profiler cannot see.

V8's generational model, and why it matters to you

V8 splits the JS heap by age, on the generational hypothesis: most objects die young.

CollectionRegionCostFrequencyTrigger you can feel
Minor GC (Scavenger)Young generation (new space)Cheap; proportional to surviving objectsVery frequentUsually sub-millisecond; invisible
Major GC (Mark-Compact)Whole heapExpensive; proportional to live heap sizeInfrequent — until you leakThis is the long task

The consequence that connects this module to fe-01: major GC cost scales with live heap size. A leak does not just consume memory — it makes every subsequent major GC longer and more frequent, because the collector has more surviving objects to trace and compact. That is why leaks present as scheduling problems long before they present as memory problems. Modern V8 does most of this work concurrently and incrementally, which reduces the pauses but does not remove the scaling.


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.

The diagnostic misdirection this module prevents

The characteristic organisational failure here has a precise shape, and it wastes more time than any other bug class in this book:

  1. INP regresses. Long tasks appear in the field data. No recent change explains it.
  2. The team profiles an interaction, finds GC in the trace, and treats GC as background noise.
  3. They optimise the handler — the only code in the trace they own.
  4. Nothing improves, because the handler was never the problem: the heap was.

The tell is that the regression correlates with session age, not with a deploy. Nobody looks at session age unless someone tells them to, and telling them to is the intervention. It costs one question in a review and saves a quarter.


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

3.1 Reachability, precisely

"Reachable" means: there exists at least one reference path from at least one GC root. Three implications people routinely get wrong:

Any path is enough. Cutting nine of ten references frees nothing. This is why "I removed the listener" is not a fix if a Map still holds the same object — and why the retainer path shown by DevTools is one path, not necessarily the only one. After cutting an edge, re-snapshot.

Cycles are not leaks. a.b = b; b.a = a with nothing else pointing at either is collectable. Tracing collectors handle cycles natively — this is the single biggest thing they buy you over reference counting. If someone proposes breaking a cycle "so it can be collected," the cycle is not the problem; something outside it is holding a reference in.

Roots include things that do not look like variables. A setInterval you never cleared is a root. A listener on window is a root. A pending promise that will never settle is a root, and it retains its entire .then chain and every closure in it — which is why an abandoned fetch with no AbortController retains the component that issued it.

3.2 Two heaps, and choosing the instrument before you measure

This is the highest-value practical section in the module, because picking the wrong instrument produces a confident wrong answer rather than an obvious failure.

InstrumentSees JS heapSees DOM/C++ heapCostUse for
performance.memoryYesNoFreeRough JS-only trend; not a leak verdict
Runtime.getHeapUsage (CDP)YesNoCheapAutomated JS-heap slope measurement
Heap snapshot (DevTools / CDP)YesYes — native nodes with detachednessExpensive, pausesRetainer paths; detached-node counts
performance.measureUserAgentSpecificMemory()YesYesAsync, sampledWhole-renderer figure — requires COOP+COEP
DevTools Performance → Memory trackCounters onlyNode/listener countsCheapGrowth shape over a session

Measured, and this is the number to remember:

Stagedetached nodesdetached bytesJS heap
built + attached00.0 KB0.84 MB
removed from DOM, JS refs held5000507.8 KB0.84 MB
JS refs released00.0 KB0.74 MB

Releasing 507.8 KB of DOM moved the JS heap by 0.10 MB. The JS-heap graph is nearly flat across a real, large, reproducible leak.

The detachedness field is the modern tool that makes this tractable: every node in a V8 heap snapshot carries 0 (unknown), 1 (attached), or 2 (detached). Filtering a snapshot for detachedness === 2 turns "hunt for the leak" into "list the leaked nodes," which is the difference between an afternoon and a week.

3.3 The five retainer shapes

Almost every real leak is one of five shapes. Learning them means you are pattern-matching rather than exploring.

1. Detached DOM. A node is removed from the document but a JS reference survives — commonly a cached querySelector result, a chart library's internal handle, or an array of row elements. The crucial property: detached subtrees retain their entire subtree. One held reference to a leaf retains every ancestor up to the detached root, plus all their listeners and data.

2. Listener. A long-lived target (window, document, a store, an event bus) holds a closure that captures a dead component. The target is a root, so the component never dies. Measured across 300 mount/destroy cycles:

Variantretained JS heapdetached nodes
anonymous listener, never removed23.00 MB300
removeEventListener in destroy()0.08 MB0
AbortController signal + abort()0.09 MB0

Note the property that does not appear in the numbers: the anonymous-listener variant cannot be repaired later. There is no reference to the function that was registered, so no code you write afterwards can remove it. It is not a slow leak; it is an unfixable one, short of replacing the target.

3. Timer. setInterval whose callback closes over a dead scope. The timer registry is a root, so the interval keeps the entire scope alive and keeps executing — burning CPU on a component nobody can see. setTimeout self-rescheduling loops have the same property.

4. Side table. A module-level Map/Set/registry keyed by objects, with no deletion. Measured, 2,000 detached nodes as keys:

Side tableretained JS heapdetached nodes still alive
Map38.34 MB2000
WeakMap0.09 MB0

Identical code except the constructor. A Map holds keys strongly, so every node it has ever seen is immortal along with its metadata — unreachable from the document and from application code, and still not collectable.

5. Shared context. The subtlest, and the one that survives code review. See §3.4.

3.4 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. B retains 5,744× what A retains, because of a function nobody calls.

function makeHandler() {
  const big = new Array(50_000).fill(0);

  // Never called. Never exported. Retains `big` anyway — for every sibling.
  function debugDump() { console.log(big.length); }

  return () => 42;          // identical bytes in all three variants
}

Three consequences worth internalising:

  • "My callback doesn't reference that variable" is not a defence. Retention is a property of the scope, not the function.
  • The retaining code is often nowhere near the suspicious code. A debug helper, a commented-in logging function, or a rarely-taken error path can pin megabytes held by its siblings.
  • x = null is occasionally load-bearing. Usually it is cargo cult; variant C shows when it is not. The distinguishing question is: does a surviving closure share this scope? If yes, nulling the binding genuinely severs the edge. If no, it does nothing.

The structural fix is better than the null: narrow the scope. If big lives in a smaller block or a separate function that returns only what is needed, no sibling can capture it and the problem cannot recur.


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
Retainer pathThe chain of references from a root to the object; the actual bug report
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
WrapperThe V8 object representing a C++ DOM node to JavaScript; the cross-heap edge
Generational hypothesisMost objects die young — the assumption V8's two-generation heap is built on
Minor GC (scavenger)Frequent, cheap, collects the young generation; cost scales with survivors
Major GC (mark-compact)Infrequent, expensive, walks the whole heap; cost scales with live heap size
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, never for critical cleanup
Soak testLong-running repeated-cycle test asserting bounded growth; the only automation that catches leaks
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.

Scope is the unit of retention, not the variable. Because V8 allocates per-scope Contexts, the question "what does this closure retain?" is really "what does its scope contain, and does any sibling capture it?" Narrow scopes are a memory-safety technique, not a style preference.

Leaks are a time-domain bug, so they need a time-domain test. Nothing in a unit test, component test, or one-minute E2E run spends enough time for a leak to appear. The only automation that catches them asserts bounded growth over N cycles, and it belongs in a nightly job — because it is slow and mildly flaky by nature, and putting it in PR CI gets it disabled within a month.


6. Applied: the decisions this module actually makes

6.1 The five shapes, and where the fix belongs

ShapeRootTypical fixFix altitude
Detached DOMa JS reference to a removed nodedrop the reference on teardownlocal
Listenerthe long-lived event targetAbortController signal at registrationstructural
Timerthe timer registryclear on teardown; prefer AbortSignal-aware wrappersstructural
Side tablemodule-level Map/Set/registryWeakMap, or an explicit eviction policyarchitectural
Shared contextV8 Context shared by sibling closuresnarrow the scope; null the bindinglocal, but invisible

The altitude column is the Principal-relevant one. Detached-DOM leaks are usually one bad line and one good line. Side-table leaks are a design question — who owns this cache and when does an entry die? — and fixing one instance without answering that question guarantees the next one.

6.2 Why AbortController beats removeEventListener

Measured, both fixes work: 0.08 MB vs 0.09 MB retained across 300 cycles. They are not equivalent in the ways that matter at scale.

// requires: a stable reference, a second call, and remembering to make it
this._h = () => this.onResize();
window.addEventListener('resize', this._h);
// ... elsewhere, later, in a different function
window.removeEventListener('resize', this._h);

// teardown handle exists at registration; one abort() covers every registration
this._ac = new AbortController();
window.addEventListener('resize', () => this.onResize(), { signal: this._ac.signal });
// ... teardown
this._ac.abort();

The removeEventListener form has three separate failure modes: forgetting the call, losing the reference (an anonymous listener is unremovable — no amount of later code can fix it), and passing different options than at registration. The signal form has one teardown call regardless of how many listeners were registered, and it composes with fetch, addEventListener, and any API that accepts a signal.

This is the specification's heuristic "make invalid states difficult to represent" applied to lifecycle rather than to data — which is where it usually pays most. It is not faster. It is harder to get wrong, and at organisational scale that is the property you are buying.

6.3 When WeakMap is the wrong answer

WeakMap fixed a 38 MB leak to 0.09 MB in the measurement. It is still not a default:

  • It weakens the key, not the value. wm.set(node, { node }) retains everything, because the value's reference to the key is strong. This is a common and hard-to-see mistake.
  • Not enumerable. No .size, no iteration, no way to dump it in an incident. You have traded a visible leak for an invisible cache.
  • Object keys only. Anything keyed by an id string cannot use it.
  • Non-deterministic collection. You cannot write a test that asserts an entry was freed — only that heap growth is bounded, which is a different and weaker claim.
  • It hides the real question. If entries should expire on logout, on navigation, or after 30 seconds, WeakMap implements none of that. It only handles "when the key dies", which is frequently not the intended policy.

Decision rule: use WeakMap when the key's lifetime genuinely is the entry's lifetime. When the policy is time, count, or an explicit event, you want a real cache with an eviction policy — LRU, TTL, or clear-on-event — and that is a design decision, not a data-structure swap.

The same caution applies harder to WeakRef and FinalizationRegistry. Collection timing is unspecified; a finalizer may run late, or never (a page closing does not run finalizers). Never put anything correctness-critical in a FinalizationRegistry callback — closing a file handle, releasing a lock, flushing analytics. They are diagnostics and caches, not lifecycle.

6.4 Leak detection as a statistical claim

The clean run produced R² = 0.742. Correlated-looking noise over a 12-point series is unremarkable, and it is exactly why slope alone is not evidence.

clean  ▁▁▁▁▁▁▁█████  0.68MB -> 0.69MB     slope 0.001 MB/cycle   R² 0.742
leaky  ▁▁▂▃▃▄▅▆▆▇██  2.51MB -> 22.71MB    slope 1.836 MB/cycle   R² 1.000
slopeR²Interpretation
highhighreal leak driven by the repeated operation
highlowwarming cache, lazy compilation, unsettled heap — not yet a finding
lowhighsteady tiny growth; may be real but is not urgent — quantify against session length
lowlownoise

The method, as a checklist:

  1. Force GC between cycles, or you are measuring GC scheduling, not retention.
  2. Use identical repeated cycles, or slope is meaningless.
  3. Discard the first few cycles. Lazy compilation and cache warming are real and they are not your leak.
  4. Report slope and R². Neither alone supports a conclusion.
  5. Quantify magnitude against session length. 0.05 MB/cycle is meaningless until multiplied by how many cycles a real session performs. A leak costing 4 MB over a working day does not justify a sprint; one costing 400 MB does.
  6. Run a control. Point the same harness at a path you believe is clean. Without a control you cannot distinguish your application leaking from your harness leaking — and harnesses leak.

6.5 Reviewing a diff — the six smells

  1. addEventListener on window/document/a store, with no signal and no paired removal. The single most common leak in application code.
  2. An anonymous listener on a long-lived target. Not just a leak — an unfixable one.
  3. A module-level Map/Set/array that only ever grows. Ask for the eviction policy. If the answer is "entries are small," ask for the bound.
  4. setInterval without a stored handle, or a handle cleared on only one of several exit paths.
  5. A fetch or subscription started outside a lifecycle hook — in a module body, an event handler, or a promise chain. It is outside the framework's teardown contract and will not be cleaned up.
  6. A debug/logging helper declared alongside a large local. The shared-context case; ask whether any returned closure shares that scope.

6.6 Diagnosing in the field — the ordered checklist

  1. Does it correlate with session age rather than a deploy? If yes, suspect retention before you profile anything.
  2. Decide which heap you expect to grow, then pick the instrument (§3.2). Deciding after you see the graph is how teams talk themselves out of real leaks.
  3. Take two snapshots with the suspect cycle repeated between them, GC forced before each, and diff. Filter by detachedness === 2 for DOM-shaped leaks.
  4. Read the retainer path, not the object list. Identify the root end of the path — that is what names the owning code.
  5. Cut one edge, re-snapshot. Any path is enough to retain, so a fix is only proven by the object disappearing, not by the edge being gone.
  6. Attribute before fixing. Third-party analytics and chat widgets that retain DOM across route changes are common, and recognising it is third-party is most of the work.

6.7 What breaks at organisational scale

  • Frameworks own the teardown, and their contract is easy to violate. useEffect cleanup, ngOnDestroy, onUnmounted are all ownership declarations. A subscription created outside the lifecycle hook is outside the contract. Most framework-era leaks are ownership placed in the wrong scope, not a missing cleanup call.
  • Virtualized lists multiply everything. A per-row listener or side-table entry is a rounding error at 20 rows and an incident at 100,000 (fe-30). Anything per-row must be delegated or weak, and the review question for virtualization code is "what is allocated per row and who frees it?"
  • Long sessions are the test environment you do not have. The only automation that catches these is a soak test asserting bounded growth over N cycles — nightly, not in PR CI.
  • Third-party scripts leak and you cannot fix them. Options in ascending blast radius: lazy-load them, move them to an iframe with its own heap, or drop them.
  • Cross-process invisibility. Workers, iframes and extensions each have their own heap. A renderer that looks healthy may be one of several, and performance.measureUserAgentSpecificMemory() is the only in-page API that even attempts a whole-renderer figure — at the cost of requiring cross-origin isolation.
  • GC pauses present as scheduling problems. The tell is session age, not a code change.

6.8 Trusting your own measurements

Shared page across variants silently voids the experiment. The first listener-leak run measured all three variants on one page. The leaked widgets from variant 1 remained alive during variants 2 and 3, so the detached column read 300 / 300 / 300 — identical for the fixed variants and the broken one. The JS-heap column was still correct, which made the table look partially plausible and therefore harder to distrust.

Fix: a fresh page (fresh realm, fresh heap) per variant.

This is the same class of bug as the realm-reuse issue in fe-01: the harness produced clean, plausible, wrong data. Deliberately break the thing being measured and confirm the harness notices, before trusting any number it reports.


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

  8. "Reference cycles leak." Not in a tracing collector. a ↔ b with nothing else pointing in is collectable. If a cycle appears to leak, something outside it holds a reference — go find that instead.

  9. "I removed the listener, so it's fixed." Only if that was the only path. Any remaining path retains. Prove the fix by re-snapshotting, not by reading the diff.

  10. "FinalizationRegistry gives me deterministic cleanup." It gives you a callback that may run late or never. It is a diagnostic, not a lifecycle mechanism, and nothing correctness- critical belongs in it.

  11. "Anonymous listeners are just a style issue." They are unremovable. No code written later can detach them, which makes it the one leak shape where the fix requires replacing the target.

  12. "A slope proves a leak." The clean control measured R² = 0.742 at zero magnitude. Slope without linearity, magnitude, and a control is a guess with a decimal point on it.


8. 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. We measured 5,744× between two versions whose returned closure was byte-identical."
  • "Leaks show up as scheduling problems first, because major GC cost scales with live heap size. If INP is regressing with no change in the interaction path, my first question is whether it correlates with session age rather than with a deploy — and nobody checks that unless you ask."
  • "I treat WeakMap as an answer to exactly one question: is the key's lifetime the entry's lifetime? If the real policy is a TTL or an event, WeakMap implements none of it and you've just made the cache invisible in an incident."
  • "Anonymous listeners on long-lived targets are the one leak shape that can't be fixed after the fact — there's no reference to pass to removeEventListener. That's worth a lint rule rather than a code review comment."

9. Connections to other modules

  • fe-01-execution-model-scheduling — prerequisite, and the link is causal: GC pauses are long tasks, and major GC cost scales with live heap size. A leak diagnosed as a scheduling problem is the classic misdirection.
  • fe-03-object-shapes-and-jit — the other half of V8's object model. Shapes govern access cost; this module governs lifetime. Both are properties of how you construct objects.
  • 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, which is why they escape unit tests. Long-running soak tests with heap assertions are the only automation that catches them.
  • bi-04-dom-internals — the C++ side of the two-heap picture: how Blink nodes and V8 wrappers are kept alive across the boundary.
  • fw-09-query-cache — you build the eviction policy that separates a cache from a leak.
  • 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.