QuestionsWeb Performance

Finding and fixing memory leaks in SPAs

Memory LeaksHardWeb Performance

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?

What it tests

Knowing the common retain-paths in long-lived JS apps and the heap-snapshot workflow to locate them.

Approach & answer

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.

Use this technique when

Diagnosing an SPA that degrades over time; auditing cleanup in components with subscriptions/timers.

Code

// 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

References