Pattern 1 — Unbounded Cache❌ Leaking
// ❌ Map grows forever — never evicts const cache = new Map(); function getUser(id) { if (cache.has(id)) return cache.get(id); const user = { id, data: new Array(1000).fill(0) }; cache.set(id, user); // never deleted! return user; } // Every unique id → permanent Map entry // 10,000 users → 10,000 entries in memory
Fix + Memory Monitor✅ Fix
0 entries
Cache size
Stopped. Click Start Leak to watch the cache grow.
// ✅ FIX: size-limited cache (LRU-style) const MAX = 500; const cache = new Map(); function cacheSet(key, value) { if (cache.size >= MAX) { // evict oldest entry cache.delete(cache.keys().next().value); } cache.set(key, value); } // OR: WeakMap for object keys const weakCache = new WeakMap(); // auto-evicts when key is GC'd
// log appears here…
Pattern 2 — setInterval Without clearInterval❌ Leaking
// ❌ LEAK: no interval ID → can't stop it function startDashboard() { setInterval(async () => { const data = await fetchData(); history.push(data); // array grows forever }, 500); // no return → interval runs forever } startDashboard(); // called on mount startDashboard(); // called again → 2 intervals! // Growth: ~200KB per minute
Fix + Memory Monitor✅ Fix
0 ticks
Interval count / history
Stopped. Each "Start" creates a new non-cancellable interval.
// ✅ FIX: save the ID, return cleanup function startDashboard() { const history = []; const id = setInterval(async () => { const data = await fetchData(); history.push(data); }, 500); // Return cleanup fn return () => clearInterval(id); } // React: return from useEffect useEffect(() => { const id = setInterval(tick, 500); return () => clearInterval(id); }, []);
// each "Start Leak" adds an un-stoppable interval
Pattern 3 — Detached DOM Nodes❌ Leaking
// ❌ LEAK: reference held after removal const nodeRefs = []; // stores removed nodes function createAndRemove() { const div = document.createElement('div'); div.innerHTML = '<ul>' + '<li>'.repeat(200) + '</ul>'; document.body.appendChild(div); document.body.removeChild(div); nodeRefs.push(div); // still referenced! // div + 200 li nodes = detached } // In DevTools: filter "Detached" to find these
Fix + Memory Monitor✅ Fix
0 nodes
Detached DOM nodes
Stopped. Click Start to accumulate detached nodes.
// ✅ FIX: don't store references to removed nodes function createAndRemove() { const div = document.createElement('div'); document.body.appendChild(div); document.body.removeChild(div); // No external reference → GC can collect } // If you need to cache: use WeakRef const nodeRef = new WeakRef(div); // GC can collect div even with WeakRef // DevTools: Memory → Heap Snapshot // Filter: "Detached" → see retainers
// open DevTools → Memory tab → Heap Snapshot
// filter "Detached" to see these nodes
Pattern 4 — Closure Capturing Large Object❌ Leaking
// ❌ LEAK: closure captures largeData // even though it never uses it const handlers = []; function createHandler() { // ~8MB array captured by closure scope const largeData = new Array(100_000) .fill({ x: Math.random() }); const handler = () => { // largeData NEVER used here! console.log('clicked'); }; handlers.push(handler); // held forever return handler; }
Fix + Memory Monitor✅ Fix
0 handlers
Closures holding large arrays
Each handler captures ~8MB even though it never reads it.
// ✅ FIX: extract what you need, null the rest function createHandler() { let largeData = new Array(100_000) .fill({ x: Math.random() }); const summary = compute(largeData); // extract largeData = null; // ← allow GC immediately return () => { console.log(summary); // only holds summary }; } // The closure now holds only "summary" // largeData (~8MB) can be GC'd
// each click adds one handler holding ~100K array entries
Pattern 5 — React useEffect Without Cleanup❌ Leaking
// ❌ LEAK: new listener added on every mount // old ones never removed function MyComponent() { useEffect(() => { // This adds a listener every mount window.addEventListener( 'resize', handleResize ); // No return → no cleanup → leak! }, []); return <div>Hello</div>; } // Mount 10× → 10 resize listeners alive // Unmount has no effect on old listeners
Fix + Memory Monitor✅ Fix
0 listeners
Active event listeners
Each mount without cleanup adds a permanent listener.
// ✅ FIX: return cleanup from useEffect function MyComponent() { useEffect(() => { window.addEventListener( 'resize', handleResize ); // Return cleanup — runs on unmount return () => { window.removeEventListener( 'resize', handleResize ); }; }, []); } // Now mount/unmount 100× → always 1 listener
// click Mount to add listeners without cleanup
The Three-Snapshot Technique
The only reliable way to confirm a memory leak in Chrome DevTools.

Step 1 — Baseline
Open the page in Incognito (excludes extension memory). DevTools → Memory tab → Heap Snapshot → Take snapshot. Click the GC button (trash icon) first.

Step 2 — Action
Perform the suspected leaking action: navigate back/forth, open/close a modal, click a button 5 times. Click GC again. Take a second snapshot.

Step 3 — Confirm
Repeat the action 5–10 more times. Take a third snapshot. In the dropdown at the top of the snapshot, select Comparison. Sort by Size Delta descending. If the delta keeps growing with each repeat — confirmed leak.
Retained Size vs Shallow Size

Shallow Size — memory used by the object itself only, not counting what it references. A Map holding 50MB of data has a tiny shallow size (just the Map structure).

Retained Size — memory that would be freed if this object were removed. Includes all objects reachable ONLY through this object. Always sort by Retained Size — this is the number that matters.
Find Detached Nodes Instantly
Memory tab → Heap Snapshot → Take snapshot → In the Summary filter box, type Detached.
Every detached DOM node appears. Click any row → expand the Retainers pane at the bottom → the retainer chain shows exactly which variable is keeping it alive.
Quick Leak Diagnosis
Performance tab → check Memory checkbox → Record 60 seconds → click GC (trash icon).
If the blue JS Heap line doesn't return close to its starting value after GC — you have a leak. The sawtooth pattern going upward = growing leak. Flat sawtooth = healthy.
Symptom → Pattern Lookup

Gets slower over time with no user input → Pattern 2 (setInterval)
Grows on every SPA navigation → Pattern 3 (detached DOM)
Grows on component mount/unmount → Pattern 5 (useEffect)
Grows on each unique user action → Pattern 1 (unbounded cache)
Large retained size on closures → Pattern 4 (closure capture)
Read the tutorial