Analysis

The five retainer 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.

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.

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.

Leak detection as a statistical claim

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

slopeInterpretation
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

Two further requirements to make the claim honest:

  • Magnitude against session length. 0.05 MB/cycle is meaningless until multiplied by how many cycles a real session performs. A leak that costs 4 MB over a working day does not justify a sprint; one that costs 400 MB does.
  • A control. Run the same harness against a code path you believe is clean. Without a control you cannot distinguish your application leaking from your harness leaking — and harnesses leak.

What breaks at 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 — in a module body, an event handler, a promise chain — is outside the contract and will not be torn down. Most framework-era leaks are ownership placed in the wrong scope.
  • 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. Leaks need time. Unit and component tests never spend it, and E2E suites rarely exceed a minute. The only automation that catches these is a soak test asserting bounded growth over N cycles — which belongs in a nightly job, not in PR CI, because it is slow and mildly flaky by nature.
  • Third-party scripts leak and you cannot fix them. Analytics and chat widgets that retain DOM across route changes are common. Options in ascending blast radius: lazy-load them, move them to an iframe with its own heap, or drop them. Recognising it is third-party is most of the work — which requires the retainer path, not the growth curve.
  • 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.
  • GC pauses present as scheduling problems. A rising heap makes major GC more frequent and longer, which shows up as long tasks and INP regressions with no cause in the interaction path. Teams then optimise handlers for a quarter. The tell is that the regression correlates with session age, not with a code change — and nobody looks at session age unless someone tells them to.