Garbage Collection Deep Dive: Practical Debugging and Prevention
Advertisement
🧩 Scenario
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 tool | Best used for | Limitation |
|---|---|---|
| Two heap snapshots | Confirming a specific action causes a leak | Does not show which code path allocates |
| Allocation timeline | Finding which code path causes a steady heap growth | Harder to isolate; high overhead during recording |
| Performance tab memory graph | Seeing overall heap trend during a session | No 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
- How would you detect a memory leak in a Node.js API server that processes millions of requests per day?
- What is
FinalizationRegistryand how could you use it to observe when an object is collected? - If your heap snapshot shows thousands of
closureentries retainingArrayobjects, how would you trace which function creates them? - 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?
- What is the role of
performance.memoryin 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.
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