Step 3 — Listeners, Timers, and Who Owns Teardown

Goal

Measure the classic component leak and establish why one of the two working fixes is structurally better than the other.

Prerequisites

  • Steps 1–2 complete

Predict first

A Widget appends an element, holds a 20,000-element payload, registers a resize listener on window, and is then destroyed 300 times. Three listener strategies:

// leak   — anonymous listener on a long-lived target
window.addEventListener('resize', () => this.onResize());

// manual — keep the reference, remove it in destroy()
this._h = () => this.onResize();
window.addEventListener('resize', this._h);
// destroy: window.removeEventListener('resize', this._h)

// abort  — signal created at registration
this._ac = new AbortController();
window.addEventListener('resize', () => this.onResize(), { signal: this._ac.signal });
// destroy: this._ac.abort()

All three call destroy(), and destroy() always calls this.el.remove(). Predict retained heap and detached-node count for each.

Run

npm run listeners

Expected output:

  variant                              | retained JS heap | detached nodes
  -------------------------------------|------------------|---------------
  anonymous listener, never removed    |          23.00MB |            300
  removeEventListener in destroy()     |           0.08MB |              0
  AbortController signal + abort()     |           0.09MB |              0

What just happened

The retainer path is:

window → (event listeners) → the arrow function → its context → the Widget → this.data + this.el

window is a GC root and lives forever. The listener closes over this. So the Widget, its 20,000-element payload, and its detached DOM element are all immortal — even though destroy() ran and removed the element from the document.

Both fixes work. They are not equivalent.

The anonymous form is unrepairable after the fact: there is no reference to the registered function, so no later code can remove it. That is not a bug you fix in a follow-up commit; it is a bug you fix by rewriting the registration.

removeEventListener has three independent failure modes: forgetting the call, losing the reference, and passing different options than at registration. AbortController has one teardown call regardless of how many listeners were registered, the handle exists at registration time, and it composes with fetch() and every other signal-aware API.

This is "make invalid states difficult to represent" applied to lifecycle rather than to data — which is where the heuristic usually pays most.

Timers are the same shape. setInterval(() => this.refresh(), 1000) is a registration on a long-lived registry that closes over this. Same retainer path, same fix altitude.

Framework connection (previews fe-10): useEffect cleanup, ngOnDestroy and onUnmounted are ownership declarations. Leaks happen when the subscription is created outside the lifecycle hook — in a module body, inside a promise chain that resolves after unmount, in an event handler. The framework cannot tear down what it was never told about.

Checkpoint

docs/verification.md Checkpoint 3.

Note on the harness

Each variant runs on a fresh page. The first version of this experiment shared one page, so leaked widgets from variant 1 stayed alive through variants 2 and 3 and every row reported 300 detached nodes. The JS-heap column was still right, which made the table look partly plausible. See docs/measured-results.md.