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.
| Pattern | Symptom | Fix |
|---|---|---|
| Global listener | Component state retained after unmount | Remove in effect cleanup |
| Closure registry | Huge dataset kept via callback | Unsubscribe + narrow closure |
| Detached DOM | Removed nodes still referenced in JS | Clear refs on unmount |
| Unbounded cache | Map/array grows forever | Eviction policy or WeakRef |
| Timer / observer | Interval survives navigation | clearInterval / 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
nullassignment 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:
removeEventListenerrequires 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:
- Heap snapshot on landing page (baseline)
- Navigate to suspect route, interact, navigate away
- Force GC (DevTools trash icon), take second snapshot
- Compare Detached DOM tree and Retainers
- 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 timeAlso 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 referencedPractical 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