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.