Memory Leaks in SPAs
Find and fix SPA memory leaks: dangling listeners, uncleared timers, retained closures, detached DOM, and how to prove growth in DevTools.
- javascript
- memory-leaks
A memory leak in an SPA is retained memory that the app no longer needs after a navigation or unmount. One-page apps live for hours; small leaks compound into jank, GC thrash, and “tab using 2GB.”
Classic page loads tear everything down. SPAs keep the document and JS heap alive — cleanup is your job.
Mental model: who still points at this?
The GC frees objects only when nothing reachable can touch them. Leaks are almost always:
- Something long-lived (window, root store, module singleton, still-mounted parent) holds a reference.
- That reference graph includes data you thought was gone (old route, old component, detached nodes).
Top SPA leak sources
1. Event listeners without remove
// BAD — on every mount
useEffect(() => {
const onResize = () => setW(window.innerWidth);
window.addEventListener('resize', onResize);
// missing: return () => window.removeEventListener('resize', onResize);
}, []);
Same pattern for document, window, third-party widgets, and custom emitters.
2. Timers and rAF
useEffect(() => {
const id = setInterval(poll, 5000);
return () => clearInterval(id);
}, []);
A running interval holds the callback closure → holds component state setters → holds the fiber in React’s mental model of “still active work.”
3. Subscriptions (WebSocket, Redux, RxJS)
useEffect(() => {
const unsub = store.subscribe(onChange);
const ws = new WebSocket(url);
ws.onmessage = onMsg;
return () => {
unsub();
ws.close();
};
}, [url]);
4. Closures capturing big graphs
// Module-level cache of entire responses forever
const cache = new Map();
export function load(id) {
return fetch(`/api/${id}`).then((r) => r.json()).then((data) => {
cache.set(id, data); // never evicted
return data;
});
}
Caches without TTL/max size are intentional retention — fine until they aren’t.
5. Detached DOM still referenced
const nodes = [];
button.onclick = () => {
const el = document.createElement('div');
document.body.appendChild(el);
nodes.push(el); // keep after removeChild → detached but alive
};
DevTools “Detached elements” views highlight this class of bug.
6. Observers
IntersectionObserver, MutationObserver, ResizeObserver — always disconnect() on teardown.
How to prove a leak
- Open Performance / Memory → heap snapshot.
- Interact: navigate A → B → A repeatedly.
- Force GC, take another snapshot.
- Compare: growing retainer counts for your component constructors,
HTMLDivElement, listeners.
Chrome: Allocation instrumentation on timeline while navigating is often faster than guessing from one snapshot.
Framework habits that help
| Habit | Why |
|---|---|
| Effect cleanup returns | Symmetric mount/unmount |
| AbortController on fetch | Cancel in-flight work + avoid setState-after-unmount patterns |
| WeakMap for per-element metadata | Doesn’t keep elements alive |
| Virtualize long lists | Fewer nodes; less retained DOM |
Interview answer (out loud)
“SPAs don’t unload the document, so listeners, timers, subscriptions, and caches must be cleaned on route change or unmount. I diagnose with heap snapshots after repeated navigation looking for growing retainers — classic culprits are window listeners without remove, uncleared intervals, and module-level Maps that never evict.”
Further reading
Related
Related guides
- AbortControllerCancel fetch and other async work with AbortController and AbortSignal — timeouts, race conditions, and cleanup when components unmount.
- Array find, some, every, includesShort-circuiting array predicates: find, findIndex, some, every, and includes — when to use each and common interview traps.
- Array map, filter, reducemap, filter, and reduce as the core transform toolkit — immutability, chaining costs, reduce patterns, and when a plain loop is clearer.
- Arrow Functions Deep DiveLexical this, no arguments object, no construct, concise bodies — when arrows help and when methods and generators need classic functions.
- Async Iteration and for await...ofAsync iterables, for await...of, and streaming data — how async iterators differ from Promise.all and when to use each.