Memory Leaks & React-Specific Issues
Learn what causes memory leaks in JavaScript and React apps, how to detect them with Chrome DevTools heap snapshots, and how to fix the most common patterns — from forgotten event listeners to missing useEffect cleanup.
Table of Contents
Overview
A memory leak happens when your application allocates memory but never releases it — even after the data is no longer needed. Over time, leaked memory accumulates, causing the app to slow down, consume excessive RAM, and eventually crash the browser tab.
In JavaScript, the garbage collector automatically frees memory that's no longer reachable. Leaks occur when references to unused objects are accidentally kept alive — through forgotten event listeners, uncleaned timers, closures, or detached DOM nodes.
React introduces its own category of leaks: missing useEffect cleanup, state updates on unmounted components, and stale closures that hold onto old references. These are among the most common bugs in production React apps.
Why this matters
Memory leaks are silent killers. The app works fine for 5 minutes, then gets progressively slower. Users refresh the page to "fix" it. In interviews, knowing how to detect and fix leaks shows real production experience.
Memory Management in JavaScript
JavaScript uses automatic memory management via a garbage collector (GC). You don't manually allocate or free memory — the engine handles it. But you need to understand how it works to avoid leaks.
Allocate
Memory is allocated when you create variables, objects, arrays, functions, or DOM nodes. Every new object takes heap memory.
Use
Your code reads and writes to allocated memory. As long as a reference exists, the memory stays allocated.
Release
The garbage collector frees memory that's no longer reachable from the root (global scope, call stack). No references = eligible for GC.
Reachability — The Core Concept
The GC uses a mark-and-sweep algorithm. Starting from root references (global object, call stack, closures), it marks every object that's reachable. Anything not marked is swept (freed). A leak happens when an object is no longer needed but is still reachable.
// Memory is allocated let user = { name: "Alice", data: new Array(1000000) }; // user is reachable → GC won't free it console.log(user.name); // Setting to null removes the reference user = null; // Now the object is unreachable → GC will free it // ❌ LEAK: if something else still references it let cache = {}; cache.user = { name: "Alice", data: new Array(1000000) }; // Even if you don't need it anymore, cache.user keeps it alive // Fix: delete cache.user; or cache = {};
The mental model
Think of memory like a room. Objects are furniture. The GC removes furniture that nobody can reach (no path from the door). A leak is furniture hidden behind other furniture — you forgot about it, but there's still a path to it, so the GC can't remove it.
What is a Memory Leak?
A memory leak is memory that was allocated, is no longer needed by the application, but cannot be freed by the garbage collector because a reference to it still exists somewhere.
Memory Usage ▲ │ ╱ ← Leak: keeps growing │ ╱╱╱╱ │ ╱╱╱╱╱ │ ╱╱╱╱╱ │ ╱╲ ╱╲ ╱╲ ╱╱╱╱ │ ╱╲╱╱ ╲╱╱ ╲╱╱ ╲╱╱ ← Normal: sawtooth (GC reclaims) │╱╱ └──────────────────────────────────────────► Time Normal: Memory goes up (allocations) and down (GC). Sawtooth pattern. Leak: Memory only goes up. GC can't reclaim leaked objects.
Real-World Impact
- ⚠App gets progressively slower as GC runs more frequently on a larger heap
- ⚠Browser tab memory grows from 50MB to 500MB+ over extended use
- ⚠UI becomes janky — GC pauses block the main thread
- ⚠Eventually the tab crashes with 'Out of Memory' error
- ⚠Especially bad in SPAs where users don't refresh for hours (dashboards, chat apps)
Common Causes of Memory Leaks
These are the five most common causes of memory leaks in JavaScript applications. Each one follows the same pattern: a reference is created but never removed.
A. Unremoved Event Listeners
Every addEventListener creates a reference from the DOM element to your callback function (and everything in its closure). If you don't call removeEventListener, the callback and its closure stay in memory forever.
// ❌ LEAK: listener is never removed function setupTracker() { const data = new Array(100000).fill("tracking"); window.addEventListener("scroll", () => { // This closure holds a reference to 'data' console.log(data.length); }); } setupTracker(); // Even after setupTracker returns, the scroll listener // keeps 'data' alive forever. Called on every navigation // in an SPA = listeners pile up. // ✅ FIX: Store reference and remove on cleanup function setupTracker() { const data = new Array(100000).fill("tracking"); const handler = () => console.log(data.length); window.addEventListener("scroll", handler); // Return cleanup function return () => window.removeEventListener("scroll", handler); }
B. Timers Not Cleared
// ❌ LEAK: interval runs forever, holds reference to heavyData const heavyData = fetchLargeDataset(); setInterval(() => { updateDashboard(heavyData); }, 1000); // If the component/page is destroyed, the interval keeps running // and heavyData is never freed. // ✅ FIX: Clear the interval on cleanup const heavyData = fetchLargeDataset(); const intervalId = setInterval(() => { updateDashboard(heavyData); }, 1000); // On cleanup (component unmount, page leave): clearInterval(intervalId);
C. Closures Holding References
// ❌ LEAK: closure keeps 'hugeArray' alive function createHandler() { const hugeArray = new Array(1000000).fill("data"); return function handler() { // Even if handler only uses hugeArray.length, // the entire array is kept in memory by the closure return hugeArray.length; }; } const leak = createHandler(); // 'hugeArray' lives as long as 'leak' exists // ✅ FIX: Extract only what you need function createHandler() { const hugeArray = new Array(1000000).fill("data"); const length = hugeArray.length; // Extract the value return function handler() { return length; // Closure only holds a number, not the array }; // hugeArray can now be GC'd after createHandler returns }
D. Detached DOM Nodes
// ❌ LEAK: DOM node removed from tree but still referenced const elements = []; function addItem() { const div = document.createElement("div"); div.textContent = "Item " + elements.length; document.body.appendChild(div); elements.push(div); // JS reference kept } function removeItem() { const div = elements[0]; document.body.removeChild(div); // Removed from DOM // But 'elements' array still holds a reference! // The DOM node is "detached" — not in the tree, but not GC'd } // ✅ FIX: Remove the JS reference too function removeItem() { const div = elements.shift(); // Remove from array document.body.removeChild(div); // Remove from DOM // Now the node can be garbage collected }
E. Global Variables
// ❌ LEAK: global variables are never garbage collected window.cache = {}; function processData(userId) { const data = fetchUserData(userId); window.cache[userId] = data; // Grows forever! // Every user's data stays in memory for the entire session } // ✅ FIX: Use a bounded cache (LRU) or WeakMap const cache = new Map(); const MAX_CACHE = 100; function processData(userId) { const data = fetchUserData(userId); cache.set(userId, data); // Evict oldest entries when cache is full if (cache.size > MAX_CACHE) { const oldest = cache.keys().next().value; cache.delete(oldest); } }
Memory Leaks in React
React's component lifecycle introduces specific patterns that cause memory leaks. The most common: side effects that aren't cleaned up when a component unmounts.
A. useEffect Without Cleanup
This is the #1 React memory leak. If your effect sets up a subscription, timer, or event listener, you must return a cleanup function.
// ❌ LEAK: Event listener is never removed function ScrollTracker() { const [scrollY, setScrollY] = useState(0); useEffect(() => { const handler = () => setScrollY(window.scrollY); window.addEventListener("scroll", handler); // No cleanup! If this component unmounts and remounts, // listeners pile up. Each holds a reference to state. }, []); return <div>Scroll: {scrollY}px</div>; } // ✅ FIX: Return cleanup function function ScrollTracker() { const [scrollY, setScrollY] = useState(0); useEffect(() => { const handler = () => setScrollY(window.scrollY); window.addEventListener("scroll", handler); return () => { window.removeEventListener("scroll", handler); }; }, []); return <div>Scroll: {scrollY}px</div>; }
B. Subscriptions Not Removed
// ❌ LEAK: WebSocket stays open after unmount function LiveFeed() { const [messages, setMessages] = useState([]); useEffect(() => { const ws = new WebSocket("wss://api.example.com/feed"); ws.onmessage = (event) => { setMessages(prev => [...prev, JSON.parse(event.data)]); }; // WebSocket stays open forever! }, []); return <div>{messages.map(m => <p key={m.id}>{m.text}</p>)}</div>; } // ✅ FIX: Close WebSocket on cleanup function LiveFeed() { const [messages, setMessages] = useState([]); useEffect(() => { const ws = new WebSocket("wss://api.example.com/feed"); ws.onmessage = (event) => { setMessages(prev => [...prev, JSON.parse(event.data)]); }; return () => { ws.close(); // Clean up! }; }, []); return <div>{messages.map(m => <p key={m.id}>{m.text}</p>)}</div>; }
C. State Updates on Unmounted Components
// ❌ LEAK: Async call updates state after unmount function UserProfile({ userId }: { userId: string }) { const [user, setUser] = useState(null); useEffect(() => { fetch(`/api/users/${userId}`) .then(res => res.json()) .then(data => { // If component unmounted before fetch completes, // this tries to update state on a dead component setUser(data); }); }, [userId]); return <div>{user?.name}</div>; } // ✅ FIX: Use AbortController to cancel the fetch function UserProfile({ userId }: { userId: string }) { const [user, setUser] = useState(null); useEffect(() => { const controller = new AbortController(); fetch(`/api/users/${userId}`, { signal: controller.signal }) .then(res => res.json()) .then(data => setUser(data)) .catch(err => { if (err.name !== "AbortError") throw err; // Fetch was cancelled — component unmounted, ignore }); return () => controller.abort(); }, [userId]); return <div>{user?.name}</div>; }
D. Stale Closures
// ❌ BUG: Stale closure captures initial count value function Counter() { const [count, setCount] = useState(0); useEffect(() => { const id = setInterval(() => { // This closure captures count = 0 forever // because [] deps means it never re-runs setCount(count + 1); // Always sets to 1! }, 1000); return () => clearInterval(id); }, []); // ← Missing 'count' dependency return <div>{count}</div>; } // ✅ FIX: Use functional updater (no dependency needed) function Counter() { const [count, setCount] = useState(0); useEffect(() => { const id = setInterval(() => { setCount(prev => prev + 1); // Always uses latest value }, 1000); return () => clearInterval(id); }, []); // Safe — no external dependencies return <div>{count}</div>; }
The React cleanup rule
If your useEffect sets up anything that persists beyond the render (listener, timer, subscription, WebSocket, AbortController), it must return a cleanup function that tears it down. No exceptions.
Code Examples (Bad vs Good)
Side-by-side comparisons of leaky code and their fixes. These patterns cover the most common real-world scenarios.
Example 1: Event Listener in React
function ResizeWatcher() { const [width, setWidth] = useState(window.innerWidth); useEffect(() => { // Adds a new listener every time component mounts // Never removed — leaks on every route change window.addEventListener("resize", () => { setWidth(window.innerWidth); }); }, []); return <div>Width: {width}</div>; }
function ResizeWatcher() { const [width, setWidth] = useState(window.innerWidth); useEffect(() => { const handler = () => setWidth(window.innerWidth); window.addEventListener("resize", handler); return () => window.removeEventListener("resize", handler); }, []); return <div>Width: {width}</div>; }
Example 2: setInterval in React
function Clock() { const [time, setTime] = useState(new Date()); useEffect(() => { // Interval runs forever — even after unmount setInterval(() => { setTime(new Date()); }, 1000); }, []); return <div>{time.toLocaleTimeString()}</div>; }
function Clock() { const [time, setTime] = useState(new Date()); useEffect(() => { const id = setInterval(() => { setTime(new Date()); }, 1000); return () => clearInterval(id); // Clean up! }, []); return <div>{time.toLocaleTimeString()}</div>; }
Example 3: Fetch with Cleanup
function SearchResults({ query }: { query: string }) { const [results, setResults] = useState([]); useEffect(() => { // If query changes rapidly (typing), multiple fetches // race and the last one to resolve wins — could be stale fetch(`/api/search?q=${query}`) .then(r => r.json()) .then(data => setResults(data)); }, [query]); return <ul>{results.map(r => <li key={r.id}>{r.title}</li>)}</ul>; }
function SearchResults({ query }: { query: string }) { const [results, setResults] = useState([]); useEffect(() => { const controller = new AbortController(); fetch(`/api/search?q=${query}`, { signal: controller.signal }) .then(r => r.json()) .then(data => setResults(data)) .catch(err => { if (err.name !== "AbortError") throw err; }); return () => controller.abort(); // Cancel previous fetch }, [query]); return <ul>{results.map(r => <li key={r.id}>{r.title}</li>)}</ul>; }
The pattern
Every leaky example is missing the same thing: a cleanup function returned from useEffect. The fix is always the same shape: store a reference to the side effect, return a function that tears it down.
Detecting Memory Leaks (DevTools)
Chrome DevTools Memory tab gives you the tools to detect, measure, and trace memory leaks. Here's how to use each one.
Tool 1: Heap Snapshots
A heap snapshot captures every object in memory at a point in time. Take two snapshots and compare them to find objects that were allocated but never freed.
Open DevTools → Memory tab
Select 'Heap snapshot' as the profiling type.
Take Snapshot 1 (baseline)
Click 'Take snapshot' on a clean page load. This is your baseline — the normal memory state.
Perform the suspected leaky action
Navigate to a route and back, open/close a modal 10 times, or scroll through a list. Repeat the action multiple times to amplify the leak.
Force garbage collection
Click the trash can icon (🗑️) in the Memory tab to force GC. This ensures only truly leaked objects remain.
Take Snapshot 2
Click 'Take snapshot' again. Now compare the two snapshots.
Compare snapshots
Select Snapshot 2, change the view to 'Comparison' and select Snapshot 1. Look for objects with high 'Delta' (new objects that weren't freed). Sort by 'Retained Size' to find the biggest leaks.
Tool 2: Allocation Timeline
Select "Allocation instrumentation on timeline" and record while performing actions. Blue bars show allocations that are still alive. Gray bars show allocations that were freed. If blue bars keep growing, you have a leak.
What to Look For
| Indicator | What It Means | Action |
|---|---|---|
| Growing JS Heap | Memory keeps increasing without drops | Take heap snapshots, compare deltas |
| Detached DOM nodes | DOM elements removed from tree but still in memory | Search 'Detached' in heap snapshot |
| Large Retained Size | An object is keeping a large tree of objects alive | Check retainer path to find the root reference |
| Growing event listener count | Listeners piling up on window/document | Use getEventListeners(window) in Console |
The comparison workflow
Snapshot 1 → Action → GC → Snapshot 2 → Compare. If the delta shows objects that shouldn't exist (detached nodes, old component state, event handlers), you've found your leak. Click the object to see its "Retainers" — the chain of references keeping it alive.
Real Example Walkthrough
Let's walk through debugging a real memory leak in a React SPA — the kind of issue you'd encounter in production.
Scenario: Dashboard Gets Slower Over Time
Users report that a dashboard app becomes sluggish after 30 minutes of use. They have to refresh the page to "fix" it. The app has a sidebar with multiple views that users switch between frequently.
Step 1: Reproduce & Measure
Action: Switch between Dashboard → Analytics → Settings → Dashboard Repeat 10 times. Heap Snapshot 1 (before): 12MB Heap Snapshot 2 (after): 45MB ← 33MB leaked! After forcing GC: still 45MB. Memory is not being freed. Comparison view shows: +300 Detached HTMLDivElement nodes +10 EventListener objects (scroll handlers) +10 WebSocket objects (not closed)
Step 2: Find the Leaky Component
function AnalyticsView() { const [data, setData] = useState([]); const chartRef = useRef(null); useEffect(() => { // LEAK 1: WebSocket never closed const ws = new WebSocket("wss://api.example.com/analytics"); ws.onmessage = (e) => setData(JSON.parse(e.data)); // LEAK 2: Scroll listener never removed window.addEventListener("scroll", handleScroll); // LEAK 3: Chart library instance never destroyed const chart = new HeavyChartLibrary(chartRef.current); chart.render(data); // No cleanup function returned! }, []); return <div ref={chartRef} />; }
Step 3: Fix All Three Leaks
function AnalyticsView() { const [data, setData] = useState([]); const chartRef = useRef(null); useEffect(() => { const ws = new WebSocket("wss://api.example.com/analytics"); ws.onmessage = (e) => setData(JSON.parse(e.data)); const scrollHandler = () => handleScroll(); window.addEventListener("scroll", scrollHandler); const chart = new HeavyChartLibrary(chartRef.current); chart.render(data); // ✅ Clean up EVERYTHING return () => { ws.close(); // Close WebSocket window.removeEventListener("scroll", scrollHandler); // Remove listener chart.destroy(); // Destroy chart instance }; }, []); return <div ref={chartRef} />; }
Step 4: Verify the Fix
Same action: Switch views 10 times. Heap Snapshot 1 (before): 12MB Heap Snapshot 2 (after): 14MB ← Normal growth (2MB) After forcing GC: 12MB ← Back to baseline ✅ Comparison view: +0 Detached DOM nodes +0 Leaked event listeners +0 Leaked WebSocket objects
The debugging process
Reproduce → Snapshot → Action → GC → Snapshot → Compare → Find retainers → Fix cleanup → Verify. This systematic approach works for any memory leak.
Performance Impact
Memory leaks don't just waste RAM — they cause cascading performance problems that get worse over time.
More Frequent GC
As the heap grows, the garbage collector runs more often and takes longer. Each GC pause blocks the main thread, causing UI jank and dropped frames.
Slower Operations
Larger heaps mean slower object allocation, slower property lookups, and slower iteration. Everything gets proportionally slower as memory grows.
Tab Crashes
Chrome limits tab memory (typically 1-4GB). When the limit is hit, the tab crashes with 'Aw, Snap!' — losing all user state and work.
System-Wide Impact
Leaked memory affects the entire system. Other apps slow down, the OS starts swapping to disk, and the user's machine becomes unresponsive.
| Heap Size | GC Pause | User Experience |
|---|---|---|
| 50MB (normal) | ~5ms | Smooth, no noticeable pauses |
| 200MB (leaking) | ~20-50ms | Occasional jank, dropped frames |
| 500MB+ (severe) | ~100-200ms | Visible freezes, unresponsive UI |
| 1GB+ (critical) | ~500ms+ | Tab crash imminent, system slowdown |
Prevention Techniques
The best memory leak is one that never happens. Follow these practices to prevent leaks from entering your codebase.
Always Return Cleanup from useEffect
If your effect creates a listener, timer, subscription, or AbortController — return a function that cleans it up. Make this a habit for every useEffect you write.
Use AbortController for Fetch
Pass an AbortController signal to every fetch call. Abort it in the cleanup function. This prevents state updates on unmounted components and cancels unnecessary network requests.
Clear All Timers
Store the return value of setTimeout/setInterval and call clearTimeout/clearInterval in cleanup. Every timer must have a corresponding clear.
Use WeakRef and WeakMap
WeakMap and WeakSet hold 'weak' references that don't prevent GC. Use them for caches where you want entries to be automatically cleaned up when the key is no longer referenced elsewhere.
Avoid Global State Accumulation
Don't store unbounded data in global variables, module-level caches, or window properties. Use bounded caches (LRU) or scope data to component lifecycle.
Destroy Third-Party Library Instances
Chart libraries, map instances, rich text editors — they allocate internal state. Call their destroy/dispose method in useEffect cleanup. Check the library docs for cleanup APIs.
useEffect(() => { // 1. Set up side effects const controller = new AbortController(); const ws = new WebSocket(url); const timerId = setInterval(poll, 5000); window.addEventListener("resize", handler); // 2. Start async work fetchData({ signal: controller.signal }); // 3. Return cleanup that tears down EVERYTHING return () => { controller.abort(); ws.close(); clearInterval(timerId); window.removeEventListener("resize", handler); }; }, [dependencies]);
Common Mistakes
These mistakes cause the majority of memory leaks in React applications.
Forgetting useEffect cleanup
The most common React leak. Adding an event listener or timer in useEffect without returning a cleanup function. Every mount adds another listener, and they're never removed.
✅Always return a cleanup function from useEffect if you set up any side effect. Lint rule: react-hooks/exhaustive-deps catches some of these.
Ignoring async race conditions
Multiple rapid state changes (typing in search, fast navigation) trigger multiple fetches. Older fetches resolve after newer ones, causing stale data and leaked promises.
✅Use AbortController to cancel previous fetches. Or use a flag (let cancelled = false) and check it before setState.
Storing references in module scope
Storing component instances, DOM refs, or large data in module-level variables (outside components). These persist for the entire app lifetime and are never cleaned up.
✅Keep state inside components (useState, useRef). If you need module-level cache, use WeakMap or bounded Map with eviction.
Closures capturing large objects
Event handlers and callbacks in useEffect closures capture everything in scope. If a large array or object is in scope, the closure keeps it alive even if only a small value is needed.
✅Extract only the values you need before creating the closure. Or use useRef to hold mutable values without creating new closures.
Not destroying third-party instances
Chart.js, Mapbox, Monaco Editor, and similar libraries allocate internal state, canvas contexts, and event listeners. Not calling their destroy method leaks all of it.
✅Check the library docs for destroy/dispose/cleanup methods. Call them in useEffect cleanup.
Adding listeners to window/document without removal
window and document persist for the entire page lifetime. Listeners added to them are never automatically cleaned up — they survive component unmounts.
✅Always pair addEventListener with removeEventListener in cleanup. Use the same function reference (not an anonymous function).
Interview Questions
Memory leak questions test your understanding of JavaScript internals and real-world debugging experience.
Q:What is a memory leak in JavaScript?
A: A memory leak occurs when memory is allocated but never freed because a reference to it still exists, even though the application no longer needs it. The garbage collector can't reclaim it because it's still 'reachable.' Over time, leaked memory accumulates, causing the app to slow down and eventually crash.
Q:How does JavaScript garbage collection work?
A: JavaScript uses a mark-and-sweep algorithm. Starting from root references (global scope, call stack, closures), the GC marks every reachable object. Any object not marked is considered unreachable and its memory is freed. Leaks happen when objects are no longer needed but are still reachable through some reference chain.
Q:How do you detect memory leaks using DevTools?
A: Open the Memory tab, take a heap snapshot (baseline), perform the suspected leaky action multiple times, force GC, take another snapshot, then compare. Look for growing object counts (Delta column), detached DOM nodes, and large retained sizes. The 'Retainers' panel shows the reference chain keeping leaked objects alive.
Q:What are the most common memory leaks in React?
A: (1) useEffect without cleanup — event listeners, timers, and subscriptions not cleaned up on unmount. (2) State updates on unmounted components — async operations (fetch, setTimeout) that call setState after the component is gone. (3) Stale closures — useEffect callbacks capturing old state values. (4) Not destroying third-party library instances (charts, maps, editors).
Q:How do you prevent memory leaks in useEffect?
A: Always return a cleanup function that tears down every side effect: removeEventListener for listeners, clearInterval/clearTimeout for timers, ws.close() for WebSockets, controller.abort() for fetch requests, and instance.destroy() for third-party libraries. The cleanup runs when the component unmounts or before the effect re-runs.
Q:What are detached DOM nodes and why do they leak?
A: A detached DOM node is an element that's been removed from the document tree (removeChild) but is still referenced by JavaScript (stored in a variable, array, or Map). The GC can't free it because it's still reachable. In DevTools, search for 'Detached' in a heap snapshot to find them. Fix by nullifying the JS reference when removing from DOM.
Q:What is the difference between WeakMap and Map for caching?
A: Map holds strong references to keys — entries stay in memory as long as the Map exists, even if nothing else references the key. WeakMap holds weak references — if the key object is no longer referenced elsewhere, the entry is automatically garbage collected. Use WeakMap for caches where entries should be cleaned up when the key is no longer needed.
Q:How would you debug a React app that gets slower over time?
A: (1) Open Memory tab, take baseline snapshot. (2) Use the app normally for a few minutes (navigate between routes, open/close modals). (3) Force GC, take another snapshot. (4) Compare — look for growing detached nodes, event listeners, and component state. (5) Check the retainer path to find which component is leaking. (6) Add missing useEffect cleanup. (7) Verify with another snapshot comparison.
Practice Section
Apply your knowledge to these real-world debugging scenarios.
Growing Memory
A chat app's memory grows from 50MB to 400MB after 2 hours of use. Users don't refresh the page. The app uses WebSocket for real-time messages and stores all messages in state. How do you debug and fix this?
Answer: Debug: Take heap snapshots at 0min and 30min, compare. Likely findings: (1) All messages stored in state forever — the array grows unbounded. (2) WebSocket onmessage handler may have stale closures. Fix: (1) Limit stored messages (keep last 200, virtualize the list). (2) Use a bounded data structure or paginate older messages from the server. (3) Ensure WebSocket cleanup on unmount. (4) Consider using a ref for the message list if you don't need re-renders on every message.
State Update After Unmount
You see this warning: "Can't perform a React state update on an unmounted component." It happens when navigating away from a page that fetches data. What's causing it and how do you fix it?
Answer: Cause: An async operation (fetch, setTimeout) started in useEffect resolves after the component unmounts, and the .then() callback calls setState on the now-dead component. Fix: Use AbortController — pass its signal to fetch() and call controller.abort() in the useEffect cleanup. For setTimeout, store the ID and call clearTimeout in cleanup. The AbortController approach is cleanest because it actually cancels the network request, saving bandwidth too.
Find the Leak
This component leaks memory. Find all the issues: function Dashboard() { const [data, setData] = useState([]); useEffect(() => { const ws = new WebSocket(url); ws.onmessage = (e) => setData(JSON.parse(e.data)); window.addEventListener("resize", handleResize); const id = setInterval(fetchMetrics, 5000); }, []); return <Chart data={data} />; }
Answer: Three leaks: (1) WebSocket is never closed — ws.close() missing from cleanup. (2) Resize listener is never removed — removeEventListener missing. (3) Interval is never cleared — clearInterval(id) missing. Fix: return a cleanup function that calls ws.close(), window.removeEventListener('resize', handleResize), and clearInterval(id). Additionally, if Chart is a third-party library, its instance should be destroyed in cleanup too.
Cheat Sheet (Quick Revision)
One-screen reference for memory leak debugging and prevention.
Quick Revision Cheat Sheet
Memory leak: Memory allocated but never freed because a reference still exists.
GC algorithm: Mark-and-sweep. Marks reachable objects from roots, sweeps the rest.
Common causes: Event listeners, timers, closures, detached DOM nodes, global variables.
React #1 leak: useEffect without cleanup. Always return a teardown function.
Event listeners: addEventListener → must have matching removeEventListener in cleanup.
Timers: setInterval/setTimeout → must have clearInterval/clearTimeout in cleanup.
Fetch cleanup: Use AbortController. Pass signal to fetch, abort in cleanup.
WebSocket: ws = new WebSocket(url) → ws.close() in cleanup.
Stale closures: Use functional updater: setCount(prev => prev + 1) instead of setCount(count + 1).
Detached DOM: Removed from tree but JS still references it. Nullify the reference.
Detection: Memory tab → Snapshot 1 → Action → GC → Snapshot 2 → Compare deltas.
What to look for: Growing heap, detached nodes, increasing listener count, large retained size.
WeakMap: Weak references for caching. Entries auto-GC'd when key is unreferenced.
Third-party libs: Charts, maps, editors — call destroy/dispose in useEffect cleanup.
Normal memory: Sawtooth pattern (up and down). Leak = only goes up.