Debugging Memory Leaks Step by Step
A Process, Not a Hunt
Most engineers debug memory leaks by reading code and guessing. They scan useEffect hooks looking for missing cleanups, grep for addEventListener without removeEventListener, and hope they get lucky. Sometimes it works — for the obvious leaks. But it falls apart completely for the subtle ones — closures capturing unexpected scope, third-party libraries holding references, or DOM nodes retained by framework internals.
This chapter gives you a systematic process that works regardless of the leak's source.
Debugging a memory leak is like finding a water leak in a building. First, confirm water is actually leaking (not just normal usage). Then isolate the room (reproduce with minimal steps). Then trace the pipes backward from the wet spot to the source (retainer chain from leaked object to GC root). The fix is always the same: break the pipe that shouldn't be connected. You never need to understand the entire plumbing system — just the one bad connection.
Step 1: Confirm the Leak Exists
Before you start hunting, make sure there's actually something to hunt. Not every growing memory footprint is a leak — JavaScript applications legitimately allocate memory for caches, lazy-loaded modules, and accumulated state. You need to distinguish healthy growth from pathological leaking.
Chrome Task Manager
Open Shift+Esc (or Menu → More tools → Task Manager). Watch two columns:
- Memory Footprint — Total memory used by the tab's renderer process
- JavaScript Memory — Heap used by JavaScript objects (the number in parentheses is live heap)
Perform the suspect action 5-10 times (navigate between pages, open/close modals, etc.). If "JavaScript Memory (live)" climbs with each repetition and never decreases, you have a leak.
Performance Monitor
Open via Cmd+Shift+P → "Show Performance Monitor." This gives real-time graphs of:
- JS Heap Size — should oscillate (grow during activity, drop during GC), not monotonically increase
- DOM Nodes — should stay roughly constant. A climbing count means nodes are created and never removed
- Event Listeners — should stay constant. A climbing count means listeners are registered and never removed
Step 2: Reproduce with Minimal Steps
A leak that takes 6 hours of "normal use" to observe? You can't debug that efficiently. You need a minimal reproduction — the smallest set of actions that causes measurable memory growth.
Strategy:
- Identify the approximate area (which page, which feature)
- Isolate one action: navigate to page X and back, open modal Y and close it, switch tab Z and switch back
- Repeat that single action 10 times while watching the Performance Monitor
- If memory grows, you've found a minimal repro. If not, try a different action.
Repeat the action rapidly in a loop to amplify the leak. A leak of 500KB per action is hard to see once. Repeated 50 times, it's a 25MB increase that's obvious in the Task Manager. Write a simple script in the Console to automate the repetition if needed.
Step 3: Heap Snapshot Comparison
Now that you can reproduce the leak with one action, use the three-snapshot technique:
- Force GC (trash can icon in Memory panel)
- Take Snapshot 1 — baseline
- Perform the leaking action once
- Force GC
- Take Snapshot 2
- Perform the leaking action again
- Force GC
- Take Snapshot 3
- Select Snapshot 3 → change view to Comparison → compare against Snapshot 2
- Sort by # Delta descending
Objects with positive delta are the leaked objects. Focus on:
- Your application types (component names, custom class names)
- Detached DOM nodes (Detached HTMLDivElement, etc.)
- System types with abnormally high deltas (EventListener, IntersectionObserverEntry, etc.)
Step 4: Analyze the Retainer Tree
Click on a leaked object in the Comparison view. The Retainers panel shows every reference chain from a GC root to this object.
Read the chain from bottom to top:
Leaked Object @12345
← items[42] in Array @67890
← pendingCallbacks in Object @11111
← _callbacks in WebSocket @22222
← (GC root: WebSocket connection)
This tells you: a WebSocket connection holds a _callbacks object, which has pendingCallbacks, which contains an array of items, one of which is your leaked object. The WebSocket is a GC root because it's an active connection.
The fix is to break the weakest link in this chain — the connection that shouldn't exist after the action is undone.
Common Retainer Patterns
| Retainer Pattern | Likely Cause | Fix |
|---|---|---|
← listener in EventTarget | Event listener not removed | removeEventListener or AbortController |
← scopeVars in functionContext | Closure capturing a large scope | Null out references in cleanup |
← entries in IntersectionObserver | Observer not disconnected | observer.disconnect() in cleanup |
← __reactFiber$ in HTMLElement | React holding ref to detached DOM | Fix component unmount lifecycle |
← cache in Map/Object | Unbounded cache growth | Use WeakMap or implement cache eviction |
Step 5: Identify the GC Root
Every retainer chain ends at a GC root — that's the ultimate reason the object can't be collected. Once you recognize the common roots, you'll start spotting leak patterns instinctively:
Window/Global scope — Module-level variables, global state stores, singleton caches. If you store something at module scope, it lives forever.
Active timers — setInterval callbacks and their closures persist until clearInterval is called. A forgotten interval is one of the most common leak sources.
Active event listeners — Listeners on window, document, or other long-lived targets hold their callback closures alive. Each closure captures its enclosing scope.
Active network connections — WebSocket connections, EventSource streams, and their callback chains persist until explicitly closed.
Pending Promises — A Promise chain that never resolves holds all .then callbacks and their closures alive indefinitely.
Why closures are the root cause of most leaks
JavaScript closures capture their entire enclosing scope — not just the variables they reference. (V8 optimizes this in many cases, but not all.) A small callback function can inadvertently keep alive every variable in its parent function:
function setupWidget(container) {
const data = fetchHugeDataset(); // 50 MB
const label = container.textContent; // 20 bytes
container.addEventListener('click', () => {
console.log(label); // Only uses 'label', but 'data' may still be retained
});
}Even though the click handler only reads label, the closure may retain data because both are in the same scope. When the container is removed from the DOM but the event listener persists, the 50 MB dataset stays alive. The fix: remove the listener, or restructure the code so the callback doesn't close over the large variable.
In practice, V8's compiler is often smart enough to exclude unused variables from the closure's scope. But this optimization is not guaranteed — especially when eval, with, or debugger statements are present, which force V8 to capture everything.
Step 6: Apply the Fix Pattern
Pattern: useEffect Cleanup
useEffect(() => {
const controller = new AbortController();
window.addEventListener('resize', handleResize, {
signal: controller.signal // Auto-removes when aborted
});
const interval = setInterval(pollData, 5000);
const observer = new IntersectionObserver(handleIntersect);
observer.observe(targetRef.current);
return () => {
controller.abort(); // Removes the resize listener
clearInterval(interval); // Stops the timer
observer.disconnect(); // Disconnects the observer
};
}, []);
Pattern: AbortController for Multiple Listeners
const controller = new AbortController();
element.addEventListener('click', handler1, { signal: controller.signal });
element.addEventListener('mouseover', handler2, { signal: controller.signal });
window.addEventListener('scroll', handler3, { signal: controller.signal });
// Single call removes ALL listeners:
controller.abort();
Pattern: WeakMap for Object-Keyed Caches
// Bad: Map holds strong references — cached objects can never be GC'd
const cache = new Map();
cache.set(domElement, computedData);
// Even after domElement is removed from DOM, cache keeps it alive
// Good: WeakMap allows GC when the key is no longer referenced elsewhere
const cache = new WeakMap();
cache.set(domElement, computedData);
// When domElement is removed and no other references exist, both key and value are GC'd
Pattern: Bounded Caches with Eviction
class LRUCache {
constructor(maxSize = 100) {
this.maxSize = maxSize;
this.cache = new Map();
}
get(key) {
if (!this.cache.has(key)) return undefined;
const value = this.cache.get(key);
// Move to end (most recently used)
this.cache.delete(key);
this.cache.set(key, value);
return value;
}
set(key, value) {
if (this.cache.has(key)) this.cache.delete(key);
this.cache.set(key, value);
if (this.cache.size > this.maxSize) {
// Delete oldest (first) entry
const oldest = this.cache.keys().next().value;
this.cache.delete(oldest);
}
}
}
Step 7: Verify the Fix
You're not done until you prove it. After applying the fix, confirm the leak is actually gone:
- Repeat the exact same three-snapshot process
- Compare Snapshot 3 vs Snapshot 2
- The deltas should be zero (or very small — single digits are usually framework internals)
- Watch the Performance Monitor for 5 minutes — JS Heap Size should oscillate without upward trend
- Check DOM Nodes count — should stay stable across repeated actions
Don't just verify once and ship. Run the reproduction 50-100 times in a loop and watch the Performance Monitor. Some leaks only appear under specific timing conditions (race conditions in async cleanup, event listeners added in microtasks that fire after the cleanup runs). A leak that doesn't appear in 10 repetitions might appear in 100.
Try to solve it before peeking at the answer.
Debug this leaky React component. The code below creates a memory leak every time the component mounts and unmounts (e.g., during route navigation). Identify the leak sources and write the fix.
function LiveDashboard({ endpoint }) {
const [data, setData] = useState(null);
const chartRef = useRef(null);
useEffect(() => {
// Leak 1: WebSocket never closed
const ws = new WebSocket(endpoint);
ws.onmessage = (event) => {
setData(JSON.parse(event.data));
};
// Leak 2: Interval never cleared
const interval = setInterval(() => {
fetch('/api/heartbeat').then(r => r.json()).then(console.log);
}, 30000);
// Leak 3: Event listener on window never removed
const handleResize = () => {
if (chartRef.current) {
chartRef.current.resize();
}
};
window.addEventListener('resize', handleResize);
// Leak 4: MutationObserver never disconnected
const observer = new MutationObserver((mutations) => {
mutations.forEach(m => console.log('DOM changed:', m));
});
if (chartRef.current) {
observer.observe(chartRef.current, { childList: true, subtree: true });
}
}, [endpoint]);
return <div ref={chartRef} className="dashboard-chart" />;
}- 1Follow the seven-step process: confirm → reproduce → snapshot comparison → retainer analysis → GC root identification → fix → verify.
- 2Use Task Manager and Performance Monitor to confirm a leak exists before hunting for it.
- 3The three-snapshot technique (baseline → action → repeat action → compare 3 vs 2) isolates per-action leaks from one-time allocations.
- 4The retainer chain is the definitive evidence — it shows exactly which reference prevents garbage collection.
- 5Every useEffect that creates subscriptions, timers, observers, or event listeners MUST return a cleanup function.
- 6AbortController is the modern pattern for cleaning up multiple event listeners and fetch requests with a single abort() call.
- 7WeakMap prevents leaks in caches keyed by objects. Regular Map/Object hold strong references that prevent GC.
Q: Describe your systematic approach to debugging a memory leak in a React SPA where memory grows 50MB every 10 minutes of normal use.
A strong answer: (1) Confirm with Task Manager — watch JS Memory (live) grow across multiple user actions. (2) Isolate the action — try different workflows while watching Performance Monitor. Is it navigation? Modal interactions? Data fetching? (3) Once isolated, use three-snapshot comparison around that action. Compare Snapshot 3 vs 2. (4) Sort by # Delta — look for Detached DOM, application-specific classes, and system types like EventListener or Observer entries. (5) Click a leaked instance, read the retainer chain from leaf to GC root. (6) The root cause is almost always a missing cleanup: unclosed WebSocket, uncleared setInterval, event listener without removeEventListener, observer without disconnect, or a module-level Map/Set that accumulates entries. (7) Fix with useEffect cleanup return, AbortController, WeakMap for caches. (8) Verify by repeating the action 50+ times and confirming memory stabilizes.