Skip to content

Debugging Memory Leaks Step by Step

advanced21 min read

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.

Mental Model

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
Quiz
The Performance Monitor shows JS Heap Size oscillating between 50MB and 80MB over 10 minutes with no upward trend. DOM Nodes climbs steadily from 1,200 to 8,400. What's happening?

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:

  1. Identify the approximate area (which page, which feature)
  2. Isolate one action: navigate to page X and back, open modal Y and close it, switch tab Z and switch back
  3. Repeat that single action 10 times while watching the Performance Monitor
  4. If memory grows, you've found a minimal repro. If not, try a different action.
Amplification trick

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:

  1. Force GC (trash can icon in Memory panel)
  2. Take Snapshot 1 — baseline
  3. Perform the leaking action once
  4. Force GC
  5. Take Snapshot 2
  6. Perform the leaking action again
  7. Force GC
  8. Take Snapshot 3
  9. Select Snapshot 3 → change view to Comparison → compare against Snapshot 2
  10. 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.)
Quiz
After the three-snapshot comparison, you see +500 Object instances and +200 Array instances. How do you determine which are actually leaked vs normal allocations?

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 PatternLikely CauseFix
← listener in EventTargetEvent listener not removedremoveEventListener or AbortController
← scopeVars in functionContextClosure capturing a large scopeNull out references in cleanup
← entries in IntersectionObserverObserver not disconnectedobserver.disconnect() in cleanup
← __reactFiber$ in HTMLElementReact holding ref to detached DOMFix component unmount lifecycle
← cache in Map/ObjectUnbounded cache growthUse WeakMap or implement cache eviction
Quiz
A retainer chain shows: Detached HTMLDivElement ← tooltipRef ← closure ← useEffect callback ← FiberNode. What's the likely fix?

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 timerssetInterval 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);
    }
  }
}
Quiz
You have a cache that maps DOM elements to computed layout data. Elements are dynamically added and removed. Which data structure prevents memory leaks?

Step 7: Verify the Fix

You're not done until you prove it. After applying the fix, confirm the leak is actually gone:

  1. Repeat the exact same three-snapshot process
  2. Compare Snapshot 3 vs Snapshot 2
  3. The deltas should be zero (or very small — single digits are usually framework internals)
  4. Watch the Performance Monitor for 5 minutes — JS Heap Size should oscillate without upward trend
  5. Check DOM Nodes count — should stay stable across repeated actions
Common Trap

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.

Challenge:

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" />;
}
Key Rules
  1. 1Follow the seven-step process: confirm → reproduce → snapshot comparison → retainer analysis → GC root identification → fix → verify.
  2. 2Use Task Manager and Performance Monitor to confirm a leak exists before hunting for it.
  3. 3The three-snapshot technique (baseline → action → repeat action → compare 3 vs 2) isolates per-action leaks from one-time allocations.
  4. 4The retainer chain is the definitive evidence — it shows exactly which reference prevents garbage collection.
  5. 5Every useEffect that creates subscriptions, timers, observers, or event listeners MUST return a cleanup function.
  6. 6AbortController is the modern pattern for cleaning up multiple event listeners and fetch requests with a single abort() call.
  7. 7WeakMap prevents leaks in caches keyed by objects. Regular Map/Object hold strong references that prevent GC.
Interview Question

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.

7/7