A single-page app gets slower the longer it runs and eventually crashes a tab. What causes leaks in SPAs and how do you find them?
Knowing the common retain-paths in long-lived JS apps and the heap-snapshot workflow to locate them.
In an SPA the page never fully reloads, so anything you fail to release accumulates across navigations until the tab bloats and GCs constantly (jank) or crashes. A memory leak in JS means objects are still REACHABLE from a root (so the garbage collector can't free them) even though your code will never use them again. The usual retain-paths: (1) Event listeners not removed — addEventListener on window/document/a shared bus from a component that later unmounts keeps the handler (and its closed-over component state and DOM) alive. Remove them on teardown (or use AbortController's signal). (2) Timers/intervals — a setInterval never cleared keeps its callback and closure forever. (3) Detached DOM nodes — you removed a node from the document but still hold a reference to it (in an array, a closure, a cache), so the whole subtree is retained. (4) Closures capturing large objects — a long-lived callback that closes over a big array pins it. (5) Growing caches/maps keyed by things that never get evicted — use WeakMap/WeakRef so entries can be collected when the key is gone, or bound the cache (LRU). (6) Framework-specific: subscriptions/observables/stores not unsubscribed on unmount. Finding them: in DevTools Memory panel, take a heap snapshot, exercise the suspected flow (navigate in and out several times), take another snapshot, and use the 'Comparison' view to see what grew; sort by retained size and look at the retainers path to find WHO is holding the object — detached nodes show up flagged. The Performance panel's memory timeline showing a sawtooth that trends UP across repeated actions is the signature. The 'take 3 snapshots' technique (baseline → do action → snapshot → undo action → snapshot) isolates objects that should have been freed but weren't. Fix is almost always: pair every subscribe/addListener/setInterval/retain with its teardown in the component's cleanup.
Diagnosing an SPA that degrades over time; auditing cleanup in components with subscriptions/timers.
// LEAK: listener + interval survive unmount, pinning state and DOM
useEffect(() => {
window.addEventListener('resize', onResize);
const id = setInterval(poll, 1000);
bus.subscribe(onEvent);
// no cleanup -> accumulates on every mount/unmount
});
// FIXED: tear everything down; AbortController removes all its listeners at once
useEffect(() => {
const ctrl = new AbortController();
window.addEventListener('resize', onResize, { signal: ctrl.signal });
const id = setInterval(poll, 1000);
const unsub = bus.subscribe(onEvent);
return () => { ctrl.abort(); clearInterval(id); unsub(); };
}, []);
// Cache that lets entries be GC'd when the key object is gone
const cache = new WeakMap(); // vs. Map, which retains keys forever