Back to Browser Internals
Browser Internals
hard
senior

What are the most common causes of memory leaks in single page applications?

SPAs leak memory when references survive after a route or component unmounts. Detached DOM, event listeners, timers, and closures are the usual suspects.

9 min read·~15 min to think through

In a traditional multi-page app the browser tears down the JS heap on every navigation. In a single-page app the heap is long-lived — it spans the whole session, and anything still reachable from a GC root (globals, the DOM tree, active timers, the current call stack) survives. A memory leak in an SPA is therefore not "memory the engine forgot about" but "memory that some reference is still pointing at, by mistake." The job is to find that reference.

The four canonical leak shapes:

  1. Detached DOM: a node has been removed from the live DOM tree but JS still holds it. Common causes: a useRef populated then never cleared, a cache like { [id]: domNode } that survives re-renders, jQuery-style selectors stored on a module-level object, or an old portal target still referenced by a Popover component. Detached subtrees are particularly bad because each parent retains all descendants — one stale ref pins thousands of nodes.
  1. Lingering subscriptions: addEventListener without a matching removeEventListener, RxJS .subscribe() without .unsubscribe(), open WebSocket / EventSource handles, MutationObservers, IntersectionObservers, ResizeObservers, BroadcastChannel listeners. The listener closure captures the component's state and the DOM target it listens to — both become unreclaimable.
  1. Timers: setInterval not cleared on unmount keeps firing forever, holding closures and any rendered state captured at mount. Self-rearming setTimeout chains are worse because they're invisible in DevTools' timer list — you have to trace the call.
  1. Closures over large state: a callback stored in a useCallback with no deps captures the very first render's data; a module-level cache (e.g. SWR, your own Map) accumulates request → response payloads forever; an event emitter retains every subscriber's bound this.

Debugging workflow with Chrome DevTools:

  1. Open Memory → Heap snapshot, take a baseline.
  2. Perform the suspected action 5–10 times — usually "navigate into a route, then leave it" or "open and close a modal." Repeating amplifies the signal above noise.
  3. Take a second snapshot. Switch to Comparison view against the baseline and sort by # Delta.
  4. Look for class names like Detached HTMLDivElement, Detached HTMLCanvasElement, or component instance names. Expanding a row shows Retainers, which is the path of references holding it alive.
  5. Walk retainers upward to the GC root. The first thing you don't recognize is usually the leak. Common GC roots: the global window, a long-lived module variable, a timer, a Promise that never resolved.

For continuous monitoring use Performance Monitor (live JS heap line chart) and watch whether the chart's baseline trends upward across forced GCs (the trash-can icon). A healthy SPA sees a sawtooth that returns to a flat baseline; a leaky one sees the baseline climb.

Prevention patterns: always return a cleanup function from useEffect that mirrors every subscription you opened; prefer AbortController for fetches and listeners (one abort() cleans many things up); use WeakMap / WeakRef for caches keyed by DOM nodes; never store DOM nodes in module-level objects; bound any LRU cache with a max size.

Code

tsx
// LEAK — listener never removed
useEffect(() => {
  window.addEventListener("scroll", onScroll);
}, []);

// FIX
useEffect(() => {
  window.addEventListener("scroll", onScroll);
  return () => window.removeEventListener("scroll", onScroll);
}, [onScroll]);

// LEAK — interval keeps the closure (and its captured state) alive
useEffect(() => {
  const id = setInterval(tick, 1000);
  // missing return → leaks across hot reloads & route changes
}, []);

// FIX
useEffect(() => {
  const id = setInterval(tick, 1000);
  return () => clearInterval(id);
}, [tick]);
Common React leak pattern + the fix

Follow-up questions

  • How do you detect a leak in production without DevTools?
  • When does a closure stop being a leak — what makes it 'too much retained state'?
  • What is the impact of React StrictMode double-invocation on leak detection?

Common mistakes

  • Treating a one-time growth as a leak — leaks are *unbounded* growth across repeated actions.
  • Forgetting that React refs survive renders unless explicitly cleared.
  • Using `useCallback`/`useMemo` deps incorrectly so the closure captures stale, large state.

Performance considerations

  • Heap growth → more GC pauses → jank.
  • Detached DOM also retains attached layout/style data, multiplying real cost.

Edge cases

  • Service workers and their caches are GC'd separately — `caches.keys()` may grow forever.
  • DevTools' own retention of console-logged objects can mask real leaks; clear console between snapshots.

Real-world examples

  • Any infinite-scroll list that pushes into an array without windowing — the heap grows linearly forever.

Senior engineer discussion

Senior interviews dig into Heap Sampling APIs (`performance.measureUserAgentSpecificMemory()`), how to wire up automated leak regression tests in Playwright, and how the V8 GC's young/old generations affect what you actually observe.

Related questions