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:
- 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.
- 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.
- 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
| Capability | Evidence you have it |
|---|---|
| Find a leak from a snapshot | Produce the retainer path, not the object, and name which edge to cut |
| Pick the right instrument | Know before measuring whether the suspected leak is JS-heap or DOM-heap shaped |
| Make a defensible claim | Report slope and R² over identical GC-separated cycles, with a control |
| Fix at the right altitude | Distinguish a one-line detached-node fix from a cache-ownership design question |
| Connect symptoms to cause | Recognise 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 guarantees | What it therefore cannot do | Which produces |
|---|---|---|
| Never frees something reachable | Distinguish "reachable" from "wanted" | Leaks, as unintended reachability |
| Frees everything unreachable, eventually | Tell you when | Non-deterministic teardown; untestable freeing |
| Traces the whole object graph | Do it for free | GC 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.
| Collection | Region | Cost | Frequency | Trigger you can feel |
|---|---|---|---|---|
| Minor GC (Scavenger) | Young generation (new space) | Cheap; proportional to surviving objects | Very frequent | Usually sub-millisecond; invisible |
| Major GC (Mark-Compact) | Whole heap | Expensive; proportional to live heap size | Infrequent — until you leak | This 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:
- INP regresses. Long tasks appear in the field data. No recent change explains it.
- The team profiles an interaction, finds GC in the trace, and treats GC as background noise.
- They optimise the handler — the only code in the trace they own.
- 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.
| Instrument | Sees JS heap | Sees DOM/C++ heap | Cost | Use for |
|---|---|---|---|---|
performance.memory | Yes | No | Free | Rough JS-only trend; not a leak verdict |
Runtime.getHeapUsage (CDP) | Yes | No | Cheap | Automated JS-heap slope measurement |
| Heap snapshot (DevTools / CDP) | Yes | Yes — native nodes with detachedness | Expensive, pauses | Retainer paths; detached-node counts |
performance.measureUserAgentSpecificMemory() | Yes | Yes | Async, sampled | Whole-renderer figure — requires COOP+COEP |
| DevTools Performance → Memory track | Counters only | Node/listener counts | Cheap | Growth shape over a session |
Measured, and this is the number to remember:
| Stage | detached nodes | detached bytes | JS heap |
|---|---|---|---|
| built + attached | 0 | 0.0 KB | 0.84 MB |
| removed from DOM, JS refs held | 5000 | 507.8 KB | 0.84 MB |
| JS refs released | 0 | 0.0 KB | 0.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:
| Variant | retained JS heap | detached nodes |
|---|---|---|
| anonymous listener, never removed | 23.00 MB | 300 |
removeEventListener in destroy() | 0.08 MB | 0 |
AbortController signal + abort() | 0.09 MB | 0 |
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 table | retained JS heap | detached nodes still alive |
|---|---|---|
Map | 38.34 MB | 2000 |
WeakMap | 0.09 MB | 0 |
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):
| 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. 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 = nullis 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
| 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 |
| Retainer path | The chain of references from a root to the object; the actual bug report |
| 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 |
| Wrapper | The V8 object representing a C++ DOM node to JavaScript; the cross-heap edge |
| Generational hypothesis | Most 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 |
| 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, never for critical cleanup |
| Soak test | Long-running repeated-cycle test asserting bounded growth; the only automation that catches leaks |
| 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.
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
| Shape | Root | Typical fix | Fix altitude |
|---|---|---|---|
| Detached DOM | a JS reference to a removed node | drop the reference on teardown | local |
| Listener | the long-lived event target | AbortController signal at registration | structural |
| Timer | the timer registry | clear on teardown; prefer AbortSignal-aware wrappers | structural |
| Side table | module-level Map/Set/registry | WeakMap, or an explicit eviction policy | architectural |
| Shared context | V8 Context shared by sibling closures | narrow the scope; null the binding | local, 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,
WeakMapimplements 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
| slope | R² | Interpretation |
|---|---|---|
| high | high | real leak driven by the repeated operation |
| high | low | warming cache, lazy compilation, unsettled heap — not yet a finding |
| low | high | steady tiny growth; may be real but is not urgent — quantify against session length |
| low | low | noise |
The method, as a checklist:
- Force GC between cycles, or you are measuring GC scheduling, not retention.
- Use identical repeated cycles, or slope is meaningless.
- Discard the first few cycles. Lazy compilation and cache warming are real and they are not your leak.
- Report slope and R². Neither alone supports a conclusion.
- 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.
- 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
addEventListeneronwindow/document/a store, with nosignaland no paired removal. The single most common leak in application code.- An anonymous listener on a long-lived target. Not just a leak — an unfixable one.
- 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. setIntervalwithout a stored handle, or a handle cleared on only one of several exit paths.- A
fetchor 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. - 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
- Does it correlate with session age rather than a deploy? If yes, suspect retention before you profile anything.
- 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.
- Take two snapshots with the suspect cycle repeated between them, GC forced before each, and
diff. Filter by
detachedness === 2for DOM-shaped leaks. - Read the retainer path, not the object list. Identify the root end of the path — that is what names the owning code.
- 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.
- 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.
useEffectcleanup,ngOnDestroy,onUnmountedare 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
-
"
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. -
"Reference cycles leak." Not in a tracing collector.
a ↔ bwith nothing else pointing in is collectable. If a cycle appears to leak, something outside it holds a reference — go find that instead. -
"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.
-
"
FinalizationRegistrygives 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. -
"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.
-
"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.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. 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
WeakMapas 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,WeakMapimplements 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;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, 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.