Garbage Collection Deep Dive: Practical Debugging and Prevention

Advanced18 min interview
Skills tested:
Using Chrome DevTools Memory panel to take and compare heap snapshotsReading a heap snapshot to find detached DOM trees and retained closuresInterpreting the Allocation instrumentation timeline to pinpoint leaking code pathsIdentifying the five most common leak sources in SPAsWriting defensive patterns (cleanup functions, WeakMap, unsubscribe) to prevent leaks

Advertisement

🧩 Scenario

In a real codebase, a memory leak surfaces as heap size that grows monotonically over the session. Opening a modal adds 5MB, closing it does not reclaim that memory, and after 20 modal opens the tab is slow. Identifying the leak requires a before/after heap snapshot comparison in Chrome DevTools, reading the retainer tree, and tracing the reference that prevents collection back to application code.

Architecture Walkthrough

The Two-Snapshot Workflow

The most reliable way to confirm and locate a memory leak is to take two heap snapshots: one before the suspected action and one after it, then compare them. The Chrome DevTools Memory panel lets you compare snapshots in the "Comparison" view, which shows the delta in object count and byte size between the two snapshots. Objects that increased in count and stayed in memory are the suspects.

Before taking each snapshot, click the "Collect garbage" button (the trash can icon in the Memory panel). This runs a forced GC pass so that unreachable objects are swept before the snapshot. Without this step, the snapshot may include objects that the GC has not yet had time to collect, inflating the numbers and producing false positives.

Reading the Retainer Tree

Selecting a retained object in the snapshot shows its retainer tree: the chain of references from a GC root to that object. Reading the retainer tree from the bottom up reveals why the object was not collected. A detached DOM node retained by a JavaScript variable shows a chain like: HTMLElement <- closureVariable <- event listener <- EventTarget. Each link is a reference in the chain. The link closest to the GC root (the top of the retainer chain) is where the leak originates.

Detached DOM trees appear in the snapshot as Detached HTMLDivElement or similar. These are DOM subtrees that have been removed from the document but are still referenced from JavaScript. The retainer tree shows what is holding the reference.

Allocation Instrumentation Timeline

The two-snapshot approach is good for confirming that a specific action causes a leak. The Allocation instrumentation on timeline (the second option in the Memory panel) is better for finding which code path allocates the leaked memory. It records every allocation during a time range and shows which call stacks are responsible. Functions that allocate memory that survives past its expected scope appear as retained bars on the timeline.

Use two snapshots when you know what action causes the leak and want to quantify it. Use the allocation timeline when you have steady heap growth but do not know which user action or background operation is responsible.


Key Code Explained

// Leak pattern 1: Event listener on document/window not removed on component unmount
class DataPanel {
  constructor() {
    this.data = new Array(100_000).fill({ value: Math.random() });
    this.handleResize = this.handleResize.bind(this);
    window.addEventListener('resize', this.handleResize);
  }
  handleResize() {
    // this.data is captured in the closure — kept alive by the listener
  }
  destroy() {
    window.removeEventListener('resize', this.handleResize); // required cleanup
    this.data = null;
  }
}

// Leak pattern 2: React component with missing useEffect cleanup
function PriceTracker({ symbol }) {
  const [price, setPrice] = useState(null);

  useEffect(() => {
    const ws = new WebSocket(`wss://prices.example.com/${symbol}`);
    ws.onmessage = (e) => setPrice(JSON.parse(e.data).price);

    return () => ws.close(); // cleanup: close the WebSocket on unmount or symbol change
  }, [symbol]);

  return <div>{price}</div>;
}

// Leak pattern 3: Growing module-level cache
const cache = new Map(); // lives for the entire page session

export async function getUser(id) {
  if (cache.has(id)) return cache.get(id);
  const user = await fetchUser(id);
  cache.set(id, user); // never evicted — grows indefinitely
  return user;
}

// Fix: bounded cache with max size or WeakMap for GC-managed entries
const MAX_CACHE_SIZE = 100;

export async function getUserBounded(id) {
  if (cache.has(id)) return cache.get(id);
  const user = await fetchUser(id);
  if (cache.size >= MAX_CACHE_SIZE) {
    const firstKey = cache.keys().next().value;
    cache.delete(firstKey); // evict oldest entry (LRU-like)
  }
  cache.set(id, user);
  return user;
}

// Leak pattern 4: Retained closure over large array in an observable/subscription
function subscribeToFeed(feed) {
  const snapshot = feed.getHistoricalData(); // 50MB array

  const unsubscribe = feed.subscribe((update) => {
    processWithHistory(update, snapshot); // closure captures snapshot
  });

  return unsubscribe; // caller must call this to release the closure
}
// If the caller never calls unsubscribe(), snapshot stays in memory forever

The WebSocket useEffect pattern is the most practically important. Without the return () => ws.close() cleanup, every time symbol changes, a new WebSocket connection is opened and the old one is never closed. After 10 symbol changes, 10 WebSocket connections are alive and all 10 are receiving and processing updates, creating both a memory leak and unnecessary network traffic.


Tradeoffs

Diagnostic toolBest used forLimitation
Two heap snapshotsConfirming a specific action causes a leakDoes not show which code path allocates
Allocation timelineFinding which code path causes a steady heap growthHarder to isolate; high overhead during recording
Performance tab memory graphSeeing overall heap trend during a sessionNo object-level detail

What Interviewers Actually Check

  • Whether you can describe the two-snapshot workflow step by step, including the "collect garbage" step
  • Whether you can explain the retainer tree and how to trace it back to the leaking code
  • Whether you know when to use allocation timeline vs snapshots
  • Whether you can name at least four SPA-specific leak patterns with a concrete fix for each
  • Whether you can write a React component that cleans up all side effects on unmount

Follow-Up Questions

  1. How would you detect a memory leak in a Node.js API server that processes millions of requests per day?
  2. What is FinalizationRegistry and how could you use it to observe when an object is collected?
  3. If your heap snapshot shows thousands of closure entries retaining Array objects, how would you trace which function creates them?
  4. How does the Chrome Memory panel's "Shallow Size" differ from "Retained Size" for an object, and which one matters more for a leak investigation?
  5. What is the role of performance.memory in detecting leaks, and what are its limitations?

Common Candidate Mistakes

  • Taking one heap snapshot and guessing based on absolute numbers without a baseline comparison
  • Not forcing a GC collection before taking a snapshot, which includes objects not yet swept
  • Not knowing about the retainer tree and being unable to trace why a specific object is still alive
  • Describing the fix as "avoid closures" rather than identifying the specific reference that should be released
  • Not knowing about the Allocation instrumentation timeline as the tool for finding which code path causes steady growth

Interview Readiness Checklist

Before you leave this question, make sure you can answer:

  • Can you describe the two-snapshot workflow for finding a leak in Chrome DevTools, including the "collect garbage" step?
  • Can you explain what the retainer tree shows and how to trace it back to leaking code?
  • Can you explain the Allocation instrumentation timeline and when to use it vs snapshots?
  • Can you name five common SPA leak sources with a fix for each?
  • Can you write a React component that is fully leak-free with cleaned event listeners, cleared timers, and cancelled subscriptions?

Summary

Finding a memory leak in a JavaScript application requires the Chrome DevTools Memory panel, not code inspection alone. The two-snapshot comparison workflow takes a baseline snapshot, performs the suspected leaking action, forces a GC collection, takes a second snapshot, and compares the two. Objects that grew in count and are retained in the second snapshot are candidates. The retainer tree for each candidate traces the chain of references from a GC root to the retained object, revealing exactly which variable or listener is keeping it alive.

Common SPA leak sources in order of frequency are: missing useEffect cleanup functions (event listeners, WebSockets, subscriptions, timers), detached DOM nodes held in module-level variables, growing caches without eviction, closures over large data captured by long-lived callbacks, and third-party library listeners registered globally without a corresponding unregister call.

The Allocation instrumentation timeline complements snapshot comparison: it records which code paths allocate memory during a time window and is the right tool when heap growth is steady and not tied to a single identifiable user action.

Frequently Asked Questions

Can I force GC in production?

No. JavaScript engines decide when to run GC. In Node.js there are debug flags but they should not be used in production.

Advertisement


Stay Updated

Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.

Advertisement