Memory Leaks
โฑ๏ธ ~3-minute bite ยท solve the sandbox to master
5-Year-Old Metaphor
โ The physical, real-world picture. No jargon.๐ฆ Each leak is a room full of boxes you promised to throw out but never did โ the garbage collector only removes boxes nobody is holding.
The storage room analogy
Imagine a building with a basement storage room. The garbage collector (GC) is a custodian who clears out boxes nobody is using. The rule is simple: if anything still holds a reference to a box, the custodian cannot remove it. It doesn't matter if you "think" you're done with it.
Event listeners are like leaving a phone line open in a room you've vacated. The phone keeps ringing (resize events), the line is live, and whoever is on the other end (your component closure) is still "in the building" as far as the custodian is concerned.
Closure leaks are like a box that contains a reference card to a storage unit full of boxes. You only need the card, but because you kept the reference card in the box, the entire storage unit is "reachable" โ it can never be cleared.
Detached DOM nodes are removed rooms. The contractor tore out the room (removeChild) but you kept a photocopy of the floor plan (JS reference). The custodian sees your photocopy and can't demolish the room โ it exists as a ghost structure in memory.
Timer leaks are automatic deliveries addressed to a tenant who moved out. setInterval is a postal service. Even after the tenant (component) leaves, packages (callbacks) keep arriving and pile up, each holding the old tenant's forwarding address (closure).
Global leaks are boxes stored in the lobby โ accessible by everyone, never cleaned up, growing every time somebody drops something there without a plan to retrieve it.
ASCII: the reference chain that prevents GC
| 1 | GC cannot collect anything reachable from a root: |
| 2 | ย |
| 3 | Roots: window, global scope, call stack frames |
| 4 | ย |
| 5 | Event listener leak: |
| 6 | window โโโ 'resize' listener โโโ handleResize closure |
| 7 | โ |
| 8 | โโโโ component state (setState) |
| 9 | โ |
| 10 | โโโโ entire component tree |
| 11 | Result: component tree never GC'd after unmount |
| 12 | ย |
| 13 | Detached DOM leak: |
| 14 | JS variable โโโ detached <div> |
| 15 | โ |
| 16 | โโโโ child <span> |
| 17 | โโโโ child <button> |
| 18 | โโโโ event listeners on children |
| 19 | Result: entire subtree stays in memory |
| 20 | ย |
| 21 | Timer leak: |
| 22 | setInterval โโโ callback closure |
| 23 | โ |
| 24 | โโโโ component props |
| 25 | โ |
| 26 | โโโโ data fetched per interval |
| 27 | Result: grows unboundedly |
| 28 | ย |
| 29 | Break the chain: null out references, call removeEventListener, |
| 30 | clearInterval, use WeakRef/WeakMap for non-owning references. |
The core rule โ GC only collects unreachable objects
JavaScript uses a mark-and-sweep GC. Every object reachable from any root (window, active closures, call stack) is "live" and cannot be collected. A memory leak is simply an unintentional reference that keeps an object reachable longer than it needs to be. The fix is always the same: break the reference chain. null the variable, remove the listener, clear the timer, or use a weak reference.
Interactive Sandbox
โ Move something, see it react instantly.Leak type
โ Leaky
| 1 | // โ useEffect without cleanup |
| 2 | useEffect(() => { |
| 3 | const handleResize = () => setSize(window.innerWidth); |
| 4 | window.addEventListener('resize', handleResize); |
| 5 | // Missing: return () => window.removeEventListener(...) |
| 6 | }, []); |
| 7 | ย |
| 8 | // Component unmounts โ listener still live |
| 9 | // handleResize closure โ keeps component state alive |
| 10 | // Grows with every mount cycle |
โ Fixed
| 1 | // โ Always clean up event listeners |
| 2 | useEffect(() => { |
| 3 | const handleResize = () => setSize(window.innerWidth); |
| 4 | window.addEventListener('resize', handleResize); |
| 5 | return () => { |
| 6 | window.removeEventListener('resize', handleResize); |
| 7 | }; |
| 8 | }, []); |
Challenge
Visit all 5 leak patterns. For each one, study the leaky code on the left and its fix on the right. Can you explain WHY the leak happens before reading the gotcha?
Why Should I Care?
โ The exact interview question + the bug it kills.Exact interview questions
Q: How does the Mark and Sweep GC algorithm work?
Mark and sweep runs in two phases. In the mark phase, the GC starts from "roots" (globals, active stack frames, CPU registers) and recursively marks every object reachable via references. In the sweep phase, the GC walks the entire heap and reclaims memory for any object not marked. Modern implementations (like V8) add a "compact" step to move live objects together, reducing fragmentation. V8 uses incremental marking to spread the mark phase across multiple small pauses, avoiding long stop-the-world freezes.
| 1 | Mark phase: |
| 2 | Start: [window, stack frames] โ mark as live |
| 3 | Traverse: for each live object, mark all objects it references |
| 4 | Result: everything reachable is marked โ |
| 5 | ย |
| 6 | Sweep phase: |
| 7 | Walk entire heap |
| 8 | Unmarked object? โ reclaim memory |
| 9 | Marked object? โ keep it, unmark for next GC cycle |
| 10 | ย |
| 11 | Compact phase (optional): |
| 12 | Move live objects together |
| 13 | Eliminate memory fragmentation |
| 14 | Update all pointers to moved objects |
Q: Why do detached DOM nodes cause leaks?
When you call removeChild() or replace the DOM tree, nodes are removed from the document but may still be referenced by JavaScript variables. Since the GC can reach these nodes through JS variables, they cannot be collected โ even though they're invisible to the user. Worse, holding a reference to any single node in a subtree keeps the entire subtree alive, because each node references its children and its event listeners. The fix: set variables to null after removal, or use WeakRef which does not prevent GC.
| 1 | // Identify detached nodes in Chrome DevTools: |
| 2 | // 1. Open DevTools โ Memory โ Heap Snapshot |
| 3 | // 2. Take snapshot, filter by "Detached" |
| 4 | // 3. Yellow nodes = detached DOM retained by JS |
| 5 | ย |
| 6 | // Or use the Detached Elements profiler (Chrome 109+): |
| 7 | // DevTools โ More tools โ Detached Elements |
Q: What tools detect memory leaks?
The primary tool is Chrome DevTools Memory tab. Take two heap snapshots โ one before and one after an action โ and compare them. Objects that appear in the "added" delta and grow over repeated cycles are likely leaks. The Allocation Timeline view records allocations over time with call stacks, letting you pinpoint which code is leaking. For React specifically, React DevTools Profiler shows component unmounts and can reveal components that mount but never unmount. The performance.measureUserAgentSpecificMemory() API measures heap usage programmatically in production.
| 1 | // Programmatic heap size measurement |
| 2 | const result = await performance.measureUserAgentSpecificMemory(); |
| 3 | console.log(result.bytes); // total JS heap usage |
| 4 | ย |
| 5 | // Snapshot comparison workflow: |
| 6 | // 1. Open DevTools Memory tab |
| 7 | // 2. Take baseline snapshot (Snapshot 1) |
| 8 | // 3. Perform action suspected of leaking |
| 9 | // 4. Take Snapshot 2 |
| 10 | // 5. In Snapshot 2, select "Comparison" to Snapshot 1 |
| 11 | // 6. Sort by "# New" โ these objects were allocated and not GC'd |
React-specific leak patterns
Pattern: AbortController for async operations
fetch() calls inside useEffect are a common source of leaks โ if the component unmounts before the fetch resolves, the .then() callback still runs and calls setState on the unmounted component. Use AbortController to cancel the request on cleanup.
| 1 | // โ Cancel fetch on unmount |
| 2 | useEffect(() => { |
| 3 | const controller = new AbortController(); |
| 4 | ย |
| 5 | fetch('/api/data', { signal: controller.signal }) |
| 6 | .then(res => res.json()) |
| 7 | .then(data => setData(data)) |
| 8 | .catch(err => { |
| 9 | if (err.name !== 'AbortError') throw err; // ignore cancel |
| 10 | }); |
| 11 | ย |
| 12 | return () => controller.abort(); // cleanup: cancel in-flight request |
| 13 | }, []); |
Pattern: React StrictMode double-invoke exposes leaks
In React 18 StrictMode (development only), effects are intentionally run twice (mount โ unmount โ mount). This is designed to expose missing cleanup functions. If your component breaks or produces double-subscriptions under StrictMode, you have a missing cleanup. This is a feature, not a bug โ fix the cleanup.
| 1 | // StrictMode behavior (dev only): |
| 2 | // 1. Component mounts โ effect runs |
| 3 | // 2. Component "unmounts" โ cleanup runs (if provided) |
| 4 | // 3. Component remounts โ effect runs again |
| 5 | ย |
| 6 | // โ Idempotent effect โ safe with double-invoke |
| 7 | useEffect(() => { |
| 8 | const sub = store.subscribe(handler); |
| 9 | return () => sub.unsubscribe(); // cleanup cancels the subscription |
| 10 | }, []); |
| 11 | ย |
| 12 | // โ Non-idempotent โ breaks with double-invoke |
| 13 | useEffect(() => { |
| 14 | document.title = 'Active'; // fine |
| 15 | initOneTimeSetup(); // called twice! if no cleanup |
The Deep Dive
โ Spec refs, engine internals, the minutiae.Chrome DevTools Memory tab: complete workflow
Heap snapshot comparison
The most powerful technique for finding leaks. Take a baseline snapshot, perform a suspected leaking action N times, take a second snapshot. In the second snapshot, switch to "Comparison" view and sort by "# New" (net new allocations). Objects that consistently grow with each action are leaks.
| 1 | // Automate heap snapshot collection: |
| 2 | const client = await CDP(); |
| 3 | await client.HeapProfiler.enable(); |
| 4 | await client.HeapProfiler.takeHeapSnapshot(); |
| 5 | // ... perform action ... |
| 6 | await client.HeapProfiler.takeHeapSnapshot(); |
Allocation timeline
Records every allocation with its call stack over a time window. Start recording, perform the suspected operation, stop. Filter by "retained" to see only allocations that survived GC. The blue bars show objects still in memory; gray bars show GC'd objects.
Detached elements panel (Chrome 109+)
DevTools โ More tools โ Detached Elements. Shows a live list of every DOM element that is detached from the document but still referenced by JS. Click any element to see which JS variable holds the reference.
WeakRef and FinalizationRegistry
Introduced in ES2021, WeakRef and FinalizationRegistry give you GC-aware primitives for building caches that don't leak.
| 1 | // WeakRef: hold a reference that doesn't prevent GC |
| 2 | const cache = new Map(); |
| 3 | ย |
| 4 | function getCachedData(key, compute) { |
| 5 | const ref = cache.get(key); |
| 6 | const cached = ref?.deref(); // null if GC'd |
| 7 | if (cached) return cached; |
| 8 | ย |
| 9 | const result = compute(); |
| 10 | cache.set(key, new WeakRef(result)); // weak โ GC can collect result |
| 11 | return result; |
| 12 | } |
| 13 | ย |
| 14 | // FinalizationRegistry: callback when object is GC'd |
| 15 | const registry = new FinalizationRegistry((key) => { |
| 16 | cache.delete(key); // clean up Map entry when value is GC'd |
| 17 | }); |
| 18 | ย |
| 19 | function setCachedData(key, value) { |
| 20 | cache.set(key, new WeakRef(value)); |
| 21 | registry.register(value, key); // fire callback when value is collected |
| 22 | } |
Note: GC timing is non-deterministic. WeakRef.deref() can return null at any time after the target becomes unreachable โ always handle the null case. FinalizationRegistry callbacks run asynchronously after GC, not immediately.
Memory pressure events
Browsers can signal memory pressure to JS via the memory pressure API (experimental) or more practically through performance.measureUserAgentSpecificMemory(). For native-like apps, the navigator.deviceMemory API reports the device's RAM tier โ useful for scaling down cache sizes on low-memory devices.
| 1 | // Scale cache to available memory |
| 2 | const RAM_GB = navigator.deviceMemory ?? 4; // defaults to 4GB |
| 3 | const MAX_CACHE_ITEMS = RAM_GB < 2 ? 50 : RAM_GB < 4 ? 200 : 1000; |
| 4 | ย |
| 5 | // Measure current heap usage (requires COOP/COEP headers) |
| 6 | if (performance.measureUserAgentSpecificMemory) { |
| 7 | const { bytes } = await performance.measureUserAgentSpecificMemory(); |
| 8 | const mb = Math.round(bytes / 1024 / 1024); |
| 9 | console.log(`JS heap: ${mb}MB`); |
| 10 | } |
Quick reference: leak detection checklist
| Symptom | Likely cause |
|---|---|
| Memory grows on each route change | Missing useEffect cleanup (listeners, timers, subscriptions) |
| React warning: setState on unmounted component | Timer or async operation not cancelled on unmount |
| Detached elements in DevTools | JS variable holding removed DOM node โ null it out |
| Heap grows monotonically over time | Global cache growing without eviction policy |
| App slows after 30+ minutes of use | Accumulated closures or growing event listener count |
| Memory spikes on specific user action | Closure capturing large array/dataset โ extract primitives |
Interview Questions
โ Real questions from real interviews โ with answers.A leak is an unintentional reference keeping an object reachable. GC only collects objects with zero live references from GC roots.
Removing a node from the DOM doesn't GC it if JS still holds a reference. The entire subtree stays in memory.
Closures capture the entire lexical scope, not just referenced variables. Extract primitives before closing over a large scope.
StrictMode double-invokes effects (mountโunmountโmount) in dev, exposing missing cleanup functions as duplicate subscriptions or warnings.
Heap snapshot comparison in DevTools: take baseline, repeat the action N times, compare โ objects in the delta that grow on each repeat are leaks.
WeakMap keys are held weakly โ when the key object is GC'd, the entry is automatically removed. No manual cleanup needed for DOM-element-keyed caches.
Memory Game
โ Quick quiz โ lock the concept in long-term memory.In the Chrome Memory Allocation Timeline, what do blue bars versus grey bars indicate?