ESC

Type to search the knowledge base.

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.

advanced3 min read
  • 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:

  1. Something long-lived (window, root store, module singleton, still-mounted parent) holds a reference.
  2. 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

  1. Open Performance / Memory → heap snapshot.
  2. Interact: navigate A → B → A repeatedly.
  3. Force GC, take another snapshot.
  4. 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 guides