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 →
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
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.
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
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.
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.
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)
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)