Back to Browser Internals
Browser Internals
medium
mid

How would you identify and fix a memory leak in a production single page application?

Identify: confirm growth in Chrome DevTools Memory tab — take heap snapshots over time, compare, look for detached DOM nodes and growing object counts; use the Performance monitor / allocation timeline. Common causes: uncleared timers/intervals, un-removed event listeners, lingering subscriptions, closures holding large objects, caches without eviction, detached DOM. Fix: clean up in effect teardown / componentWillUnmount, use WeakMap, bound caches.

7 min read·~12 min to think through

A memory leak in a SPA = memory that's no longer needed but still reachable, so GC can't reclaim it. Over time the tab slows, then crashes. The approach is confirm → locate → fix → verify.

Step 1 — Confirm it's actually a leak

  • Open DevTools → Performance monitor: watch the JS heap size and DOM node count while you use the app. A sawtooth that trends upward across GCs = leak. Flat sawtooth = fine.
  • Reproduce by repeating an action (open/close a modal, navigate between routes 20×) and checking memory doesn't return to baseline.

Step 2 — Locate it with heap snapshots

In DevTools → Memory:

  1. Take a heap snapshot (baseline).
  2. Perform the suspect action several times.
  3. Take another snapshot. Use "Comparison" view to see what grew.
  4. Look for:
  • Detached DOM nodes — DOM removed from the tree but still referenced by JS (filter by "Detached").
  • Growing counts of your own objects/closures.
  • Use the retainers panel to see what is holding the object alive.
  • The Allocation instrumentation on timeline shows allocations that are never freed.

Step 3 — The usual suspects (and fixes)

CauseFix
setInterval/setTimeout never clearedclearInterval/clearTimeout in cleanup
Event listeners not removed (window, document, third-party)removeEventListener in cleanup
Subscriptions (WebSocket, store, RxJS, IntersectionObserver) not torn downunsubscribe/disconnect() in cleanup
Closures capturing large objects/DOMnull out refs; narrow what the closure captures
Caches/Maps that only growbound size (LRU) or use WeakMap/WeakRef
Detached DOM held by a JS variabledrop the reference when the node is removed
Global variables accumulating datascope properly; don't stash on window

Step 4 — In React specifically

The #1 cause is missing effect cleanup:

tsx
useEffect(() => {
  const id = setInterval(tick, 1000);
  const onResize = () => setW(window.innerWidth);
  window.addEventListener("resize", onResize);
  const sub = socket.subscribe(onMsg);

  return () => {                    // cleanup — runs on unmount / before re-run
    clearInterval(id);
    window.removeEventListener("resize", onResize);
    sub.unsubscribe();
  };
}, []);

Also: stale closures capturing old state, refs to unmounted components, and setState after unmount (an async callback resolving late).

Step 5 — Verify and prevent

  • Re-run the snapshot comparison — memory should now return to baseline after the action.
  • Prevent: lint rules for effect cleanup, code review focus on subscriptions/listeners, and production memory monitoring (e.g. performance.memory sampling, or RUM).

Senior framing

The senior answer is a methodology, not a guess: confirm with the performance monitor, isolate with comparative heap snapshots, read the retainers to find the actual holder, then fix the specific root cause. Plus the framing that leaks are "still-reachable" memory — every fix is about dropping a reference (clear, remove, unsubscribe, null, or WeakMap) so GC can do its job.

Follow-up questions

  • What's a detached DOM node and how do you find one?
  • How does WeakMap help prevent leaks?
  • What are the most common React-specific leak sources?
  • How would you monitor for memory leaks in production?

Common mistakes

  • Guessing at the cause instead of using heap snapshots and retainers.
  • Forgetting to remove event listeners on window/document.
  • Unbounded in-memory caches with no eviction.
  • Missing the cleanup function in useEffect.
  • setState on an unmounted component from a late async callback.

Edge cases

  • Third-party libraries that leak — you may need to wrap and manually dispose them.
  • Closures in long-lived event handlers capturing large data.
  • Detached nodes kept alive by a single forgotten reference.

Real-world examples

  • Dashboards left open for hours, chat apps with unclosed sockets, SPAs that leak per route navigation.

Related questions