๐Ÿณ IntuitiveFE
Login
โ† All concepts

Memory Leaks

โฑ๏ธ ~3-minute bite ยท solve the sandbox to master

0%lesson
๐Ÿง’

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

js
1GC cannot collect anything reachable from a root:
2ย 
3Roots: window, global scope, call stack frames
4ย 
5Event 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ย 
13Detached 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ย 
21Timer leak:
22 setInterval โ”€โ”€โ†’ callback closure
23 โ”‚
24 โ””โ”€โ”€โ†’ component props
25 โ”‚
26 โ””โ”€โ”€โ†’ data fetched per interval
27 Result: grows unboundedly
28ย 
29Break the chain: null out references, call removeEventListener,
30clearInterval, 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

js
1// โœ— useEffect without cleanup
2useEffect(() => {
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

js
1// โœ“ Always clean up event listeners
2useEffect(() => {
3 const handleResize = () => setSize(window.innerWidth);
4 window.addEventListener('resize', handleResize);
5 return () => {
6 window.removeEventListener('resize', handleResize);
7 };
8}, []);
retainedClosure over component state โ†’ entire component subtree kept alive
heap growth~2MB per mount/unmount cycle (typical component tree)
โš ๏ธ Gotcha: React StrictMode intentionally double-invokes effects in development to expose missing cleanups.
๐Ÿ’ก Insight: Every addEventListener must have a corresponding removeEventListener in the cleanup. The cleanup function returned from useEffect runs on unmount AND before re-running the effect.
Visited:๐Ÿ“ก๐Ÿ”’๐ŸŒณโฐ๐ŸŒ
๐ŸŽฏ

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?

Try it
๐ŸŽฏ

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.

js
1Mark 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ย 
6Sweep phase:
7 Walk entire heap
8 Unmarked object? โ†’ reclaim memory
9 Marked object? โ†’ keep it, unmark for next GC cycle
10ย 
11Compact 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.

js
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.

js
1// Programmatic heap size measurement
2const result = await performance.measureUserAgentSpecificMemory();
3console.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.

js
1// โœ“ Cancel fetch on unmount
2useEffect(() => {
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.

js
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
7useEffect(() => {
8 const sub = store.subscribe(handler);
9 return () => sub.unsubscribe(); // cleanup cancels the subscription
10}, []);
11ย 
12// โœ— Non-idempotent โ€” breaks with double-invoke
13useEffect(() => {
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.

js
1// Automate heap snapshot collection:
2const client = await CDP();
3await client.HeapProfiler.enable();
4await client.HeapProfiler.takeHeapSnapshot();
5// ... perform action ...
6await 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.

js
1// WeakRef: hold a reference that doesn't prevent GC
2const cache = new Map();
3ย 
4function 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
15const registry = new FinalizationRegistry((key) => {
16 cache.delete(key); // clean up Map entry when value is GC'd
17});
18ย 
19function 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.

js
1// Scale cache to available memory
2const RAM_GB = navigator.deviceMemory ?? 4; // defaults to 4GB
3const MAX_CACHE_ITEMS = RAM_GB < 2 ? 50 : RAM_GB < 4 ? 200 : 1000;
4ย 
5// Measure current heap usage (requires COOP/COEP headers)
6if (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

SymptomLikely cause
Memory grows on each route changeMissing useEffect cleanup (listeners, timers, subscriptions)
React warning: setState on unmounted componentTimer or async operation not cancelled on unmount
Detached elements in DevToolsJS variable holding removed DOM node โ€” null it out
Heap grows monotonically over timeGlobal cache growing without eviction policy
App slows after 30+ minutes of useAccumulated closures or growing event listener count
Memory spikes on specific user actionClosure 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.
1/4

In the Chrome Memory Allocation Timeline, what do blue bars versus grey bars indicate?