Explain memory leaks in SPAs and how to debug them
SPAs leak memory when references survive after a route or component unmounts. Detached DOM, event listeners, timers, and closures are the usual suspects.
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:
- Detached DOM: a node has been removed from the live DOM tree but JS still holds it. Common causes: a
useRefpopulated 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.
- Lingering subscriptions:
addEventListenerwithout a matchingremoveEventListener, 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.
- Timers:
setIntervalnot cleared on unmount keeps firing forever, holding closures and any rendered state captured at mount. Self-rearmingsetTimeoutchains are worse because they're invisible in DevTools' timer list — you have to trace the call.
- Closures over large state: a callback stored in a
useCallbackwith no deps captures the very first render's data; a module-level cache (e.g. SWR, your ownMap) accumulates request → response payloads forever; an event emitter retains every subscriber's boundthis.
Debugging workflow with Chrome DevTools:
- Open Memory → Heap snapshot, take a baseline.
- 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.
- Take a second snapshot. Switch to Comparison view against the baseline and sort by # Delta.
- 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. - 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
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.