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:

PolicyMechanismWhen it is right
key lifetimeWeakMapentry is meaningful only while the key object lives
timeTTL / staleTime + gcTimeserver data with a known freshness window
countLRUbounded memory is the hard constraint
eventclear on logout / navigation / tenant switchcorrectness 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.