You notice a memory leak in a production SPA—how do you identify and fix it
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.
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:
- Take a heap snapshot (baseline).
- Perform the suspect action several times.
- Take another snapshot. Use "Comparison" view to see what grew.
- 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)
| Cause | Fix |
|---|---|
setInterval/setTimeout never cleared | clearInterval/clearTimeout in cleanup |
Event listeners not removed (window, document, third-party) | removeEventListener in cleanup |
Subscriptions (WebSocket, store, RxJS, IntersectionObserver) not torn down | unsubscribe/disconnect() in cleanup |
| Closures capturing large objects/DOM | null out refs; narrow what the closure captures |
| Caches/Maps that only grow | bound size (LRU) or use WeakMap/WeakRef |
| Detached DOM held by a JS variable | drop the reference when the node is removed |
| Global variables accumulating data | scope properly; don't stash on window |
Step 4 — In React specifically
The #1 cause is missing effect cleanup:
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.memorysampling, 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.