Topics
v6.0.0
On this page · sections

Memory Leaks in SPAs

In long-lived single-page applications, memory leaks occur when objects are logically dead but still reachable from GC roots — causing monotonically rising heap across route changes.

PatternSymptomFix
Global listenerComponent state retained after unmountRemove in effect cleanup
Closure registryHuge dataset kept via callbackUnsubscribe + narrow closure
Detached DOMRemoved nodes still referenced in JSClear refs on unmount
Unbounded cacheMap/array grows foreverEviction policy or WeakRef
Timer / observerInterval survives navigationclearInterval / disconnect

GC and Reachability

Definition

JavaScript GC frees unreachable objects. Roots: global object, call stack, live closures, DOM tree. A memory leak = logically dead but still referenced.

Generational hypothesis: most objects die young. V8 uses young generation (Scavenge, frequent) and old generation (Mark-Sweep, incremental/concurrent).

// NOT a leak — eligible for GC when fn returns function temp() { const big = new Array(1e6).fill(0); return big.length; } temp(); // LEAK — global accidental retention const leakedCache = []; function remember(obj) { leakedCache.push(obj); // never evicted } if (performance.memory) { console.log({ usedMB: (performance.memory.usedJSHeapSize / 1048576).toFixed(1), }); }

Leaks show as monotonically rising heap across navigations that should free memory. WeakRef observes collection — doesn’t fix leaks.

Interview: Leak = reachable but logically unused. GC timing is non-deterministic — don’t rely on null assignment for instant reclaim.

Event Listener Leaks

Definition

addEventListener on window, document, or singletons keeps the closure environment alive. If never removed on unmount, the entire component graph stays reachable.

// LEAK — no remove on unmount function mountLeakyPanel(state) { function onScroll() { console.log(state.userId); } window.addEventListener('scroll', onScroll); } // FIXED — paired remove function mountPanel(state, onUnmountRef) { function onScroll() { console.log(state.userId); } window.addEventListener('scroll', onScroll); onUnmountRef.current = () => { window.removeEventListener('scroll', onScroll); }; } // React pattern // useEffect(() => { // const handler = () => { ... }; // window.addEventListener('resize', handler); // return () => window.removeEventListener('resize', handler); // }, []);

Same pattern: setInterval, WebSocket, ResizeObserver, missed AbortController.abort().

AbortSignal pattern: pass signal to addEventListener; controller.abort() removes all listeners on that signal. Passive listeners still need removal.

Interview: removeEventListener requires the same function reference — store the handler in a variable.

Closures Retaining Stale State

Definition

Long-lived registries (event buses, global stores, module-level arrays) holding callbacks that close over large mount-time data.

const subscribers = []; function subscribe(fn) { subscribers.push(fn); return () => { const i = subscribers.indexOf(fn); if (i >= 0) subscribers.splice(i, 1); }; } // LEAK — callback closes over hugeDataset forever function mountDashboard(hugeDataset) { subscribe(() => process(hugeDataset)); // never unsubscribes on unmount } // FIXED — unsubscribe + avoid closing over full dataset function mountDashboard(getData) { const unsub = subscribe(() => process(getData())); return unsub; }

In React: return cleanup from useEffect. Avoid storing full props/state in module-level caches. Use refs for latest values without expanding closure scope.

Interview: Long-lived subscriber + mount-time closure = classic SPA leak. Always return cleanup.

Detached DOM Nodes

Definition

A detached DOM node is removed from the document but still referenced in JavaScript — GC cannot reclaim it or its subtree.

let leakedNode = null; function mountWidget() { const div = document.createElement('div'); div.innerHTML = '<span>Heavy subtree</span>'; document.body.appendChild(div); leakedNode = div; // retains after remove } function unmountWidget() { document.body.removeChild(leakedNode); // leakedNode still references entire subtree — LEAK } // FIXED function unmountWidgetFixed() { document.body.removeChild(leakedNode); leakedNode = null; }

Common causes: caches keyed by DOM nodes, closure over element, forgotten refs in devtools-like patterns, third-party widgets not destroyed on unmount.

Libraries (charts, maps) often expose .destroy() — call in cleanup. React refs holding DOM after unmount can leak if copied to module scope.

Interview: Detached = not in document tree but JS still references it. Check retainers in Memory panel.

DevTools Diagnosis Workflow

Definition

Repeatable workflow for React SPAs:

  1. Heap snapshot on landing page (baseline)
  2. Navigate to suspect route, interact, navigate away
  3. Force GC (DevTools trash icon), take second snapshot
  4. Compare Detached DOM tree and Retainers
  5. Repeat 3–5 cycles — monotonic growth confirms leak
Memory panel → Heap snapshot Filter: Detached Retainers chain → find closure / listener / global Performance monitor → JS heap size over time

Also check: Performance → Memory checkbox during recording. performance.memory (Chrome, non-standard) for coarse RUM monitoring.

Fix pattern: symmetric register/unregister in useEffect cleanup. Audit third-party scripts on route change. bfcache may freeze page with listeners — handle pageshow/pagehide.

Interview: Snapshot diff after forced GC is the gold standard. Look for Detached DOM and listener retainers.

Senior Interview Summary

Mental model

Reachable from roots → kept alive Unreachable → eligible for GC Leak → logically dead but still referenced

Practical rules

  • Every global listener/observer/timer needs paired cleanup
  • Unsubscribe from registries on unmount
  • Null out refs to DOM nodes after removal
  • Cap cache size; WeakRef for optional caches
  • Heap snapshot diff after 3–5 route cycles

What to say in interviews

“I’d take heap snapshots before and after navigating away from the suspect route, force GC, and look for Detached DOM nodes and retainer chains pointing to closures or global listeners. In React every useEffect that registers a window listener, interval, or WebSocket returns a cleanup function.”

Common traps

  • Assuming React auto-cleans manual window listeners
  • Anonymous handlers that can’t be removed
  • Module-level arrays that accumulate forever
  • Third-party widgets without destroy on unmount

Last updated on