Broader Ideas
Leaks are scheduling problems in disguise
The most valuable connection in this module runs back to fe-01. A growing heap makes major GC
more frequent and longer. Major GC is a long task. So a leak presents as:
- INP regressions with no change in the interaction path
- jank that worsens the longer a session runs
- "the app gets slow after a few hours", which is the specification's own case study
The diagnostic that separates them: a scheduling problem is reproducible on a fresh page load; a leak is not. If a performance complaint correlates with session age rather than with a release, take a heap trace before anyone touches a handler. Almost nobody checks session age unless it is written into the triage runbook — which is a Principal-level artifact, not a technical fix.
useEffect cleanup is an ownership declaration
Every framework lifecycle teardown hook is this module's ownership rule in framework clothing:
useEffect(() => {
const ac = new AbortController();
window.addEventListener('resize', onResize, { signal: ac.signal });
return () => ac.abort(); // <- the ownership declaration
}, []);
The leaks happen where ownership is placed outside the hook: a subscription in a module body, a listener registered inside a promise chain that resolves after unmount, a cache written from an event handler. The framework cannot tear down what it does not know about.
This is also why the stale-closure bug and the leak bug are the same bug seen from two angles: a
closure that outlives its component both reads stale state and retains that state. Developed
in fe-10.
Query caches are side tables with a policy
A server-state cache (fe-16) is exactly the side-table shape from experiment 4, and the entire
difference between a cache and a leak is the eviction policy:
| Policy | Mechanism | When it is right |
|---|---|---|
| key lifetime | WeakMap | entry is meaningful only while the key object lives |
| time | TTL / staleTime + gcTime | server data with a known freshness window |
| count | LRU | bounded memory is the hard constraint |
| event | clear on logout / navigation / tenant switch | correctness or privacy requires it |
Libraries default to time-based policies, which is why an unconfigured cache is usually bounded
but frequently wrong for the privacy case — cached data surviving a logout is a security finding,
not a memory one. That crossover is picked up in fe-25.
Soak testing is the only automation that catches this
Leaks need time, and no normal test spends it. The shape that works:
// nightly, not PR CI: slow, and mildly flaky by nature
const samples = [];
for (let i = 0; i < 30; i++) {
await doTheRepeatedThing(page);
await cdp.send('HeapProfiler.collectGarbage');
samples.push((await cdp.send('Runtime.getHeapUsage')).usedSize);
}
const { slope, r2 } = regression(samples.slice(5)); // discard warm-up
expect(slope).toBeLessThan(BUDGET_BYTES_PER_CYCLE);
Three design decisions carry the value: discard the warm-up samples, assert on slope rather
than absolute heap, and pair the slope assertion with an R² sanity check so a noisy run fails
loudly rather than silently passing. Assert detached-node counts too where the leak is DOM-shaped —
counts are exact where byte counts are not. Picked up properly in fe-33.
Memory as a budget, not an alarm
Performance budgets (fe-11) usually cover bytes over the wire and milliseconds on the main
thread, and stop there. A mature budget includes memory, expressed the way this module measures it:
- bytes per repeated operation (slope), not total heap
- detached node count after teardown — should be zero, and zero is testable
- listener count stability across mount/unmount cycles
- a session-length assumption stated explicitly, since slope only becomes a number that matters when multiplied by it
The reason to express it this way is that total heap is not actionable and varies with content, while slope attributes growth to a specific operation someone owns.
WeakRef and FinalizationRegistry
Deliberately not used in this module's experiments, because they are almost always the wrong tool
and appear in interviews far more often than in good code. FinalizationRegistry callbacks are
not guaranteed to run at all, may run arbitrarily late, and must never be used for correctness —
only for opportunistic cleanup of external resources. If a design needs to know when something was
collected, the design is wrong; make the lifetime explicit instead.
The legitimate uses are narrow: caches of expensive derived values where recomputation is acceptable, and releasing non-memory resources tied to objects the GC owns.