Problem: Your React App's Memory Usage Keeps Growing
Your React app starts at 50MB but balloons to 300MB+ after a few minutes of use. Components unmount but memory never gets released, and eventually the browser tab crashes.
You'll learn:
- Why useEffect creates memory leaks
- How to properly cleanup subscriptions and timers
- Tools to identify which hooks are leaking
Time: 12 min | Level: Intermediate
Why This Happens
React's useEffect runs side effects (API calls, subscriptions, timers) that persist after component unmount unless explicitly cleaned up. Every re-render can create new listeners without removing old ones.
Common symptoms:
- Memory usage climbs steadily in DevTools
- Browser warns "Page is using significant memory"
- Event listeners multiply (check with
getEventListeners(window)) - Intervals/timers continue after component unmounts
Solution
Step 1: Add Cleanup Functions to All useEffects
Every useEffect with subscriptions needs a return function:
useEffect(() => {
// ✅ Subscribe to event
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
// ✅ Return cleanup function
return () => {
window.removeEventListener('resize', handleResize);
};
}, []); // Empty deps = run once on mount
Why this works: React calls the cleanup function before re-running the effect and when the component unmounts, removing the listener.
If it fails:
- Event listener not removed: Make sure you're removing the exact same function reference (no inline functions)
- Cleanup not running: Check React DevTools Profiler to verify unmount is happening
Step 2: Clear All Timers and Intervals
Timers continue running even after unmount:
useEffect(() => {
// ⌠Bad: Interval leaks memory
setInterval(() => {
fetchData();
}, 5000);
}, []);
// ✅ Good: Cleanup clears interval
useEffect(() => {
const intervalId = setInterval(() => {
fetchData();
}, 5000);
return () => clearInterval(intervalId);
}, []);
Why this works: clearInterval stops the timer and allows garbage collection of any closures.
Common mistake: Using setTimeout in a dependency array that triggers constantly:
// ⌠Creates new timeout on every render
useEffect(() => {
setTimeout(() => console.log(data), 1000);
}, [data]); // Runs every time data changes
// ✅ Cleanup prevents stacking timeouts
useEffect(() => {
const timeoutId = setTimeout(() => console.log(data), 1000);
return () => clearTimeout(timeoutId);
}, [data]);
Step 3: Cancel Fetch Requests with AbortController
Fetch requests in unmounted components can cause memory leaks:
useEffect(() => {
// ✅ Create abort controller
const abortController = new AbortController();
async function loadData() {
try {
const response = await fetch('/api/data', {
signal: abortController.signal // Pass signal to fetch
});
const data = await response.json();
setData(data);
} catch (error) {
if (error.name === 'AbortError') {
// Request was cancelled, this is expected
return;
}
console.error('Fetch failed:', error);
}
}
loadData();
// ✅ Cancel on unmount
return () => abortController.abort();
}, []);
Why this works: Aborting the fetch stops processing and prevents setState on unmounted components.
If it fails:
- AbortError in console: This is normal, you can ignore or handle it in the catch block
- Still leaking: Check if you have multiple fetches without abort controllers
Step 4: Fix WebSocket and EventSource Connections
Persistent connections must be closed:
useEffect(() => {
const ws = new WebSocket('wss://api.example.com');
ws.onmessage = (event) => {
setMessages(prev => [...prev, event.data]);
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
// ✅ Close connection on unmount
return () => {
if (ws.readyState === WebSocket.OPEN) {
ws.close();
}
};
}, []);
Why this works: ws.close() terminates the connection and removes all event listeners automatically.
Step 5: Identify Leaks with Chrome DevTools
Find which components are leaking:
# 1. Open Chrome DevTools (F12)
# 2. Go to Memory tab
# 3. Take a heap snapshot
# 4. Interact with your app (navigate, open/close components)
# 5. Take another snapshot
# 6. Compare snapshots - look for "Detached" DOM nodes
You should see: Components with cleanup functions should not appear as "Detached" or have growing "Retained Size" values.
Look for:
- Detached
HTMLDivElementnodes (unmounted components still in memory) (closure)entries with large retained sizes (event handlers not cleaned up)- Growing arrays in snapshots (subscriptions accumulating)
Verification
Test Memory Cleanup
// Add this to your component for testing
useEffect(() => {
console.log('Component mounted');
return () => {
console.log('Component cleanup ran');
};
}, []);
You should see: "Component cleanup ran" in console when navigating away. If you don't see it, the component isn't unmounting properly (check your router/parent component).
Measure Memory Usage
// Run in browser console after interacting with your app
performance.memory.usedJSHeapSize / 1048576 + ' MB'
Expected: Memory should stabilize or decrease after navigating back to pages you've already visited. If it keeps climbing, you still have leaks.
What You Learned
- Every useEffect with side effects needs a cleanup function
- Timers, listeners, and fetch requests all require explicit cleanup
- Chrome DevTools Memory tab shows exactly what's leaking
- AbortController is the modern way to cancel fetch requests
Limitation: React StrictMode in development runs effects twice, making it look like leaks. This is intentional and doesn't happen in production.
Common misconception: "Empty dependency array means it only runs once" - true, but cleanup still runs on unmount.
Quick Reference: Cleanup Patterns
// Event listeners
useEffect(() => {
const handler = () => {};
window.addEventListener('event', handler);
return () => window.removeEventListener('event', handler);
}, []);
// Timers
useEffect(() => {
const id = setInterval(() => {}, 1000);
return () => clearInterval(id);
}, []);
// Fetch
useEffect(() => {
const ac = new AbortController();
fetch(url, { signal: ac.signal });
return () => ac.abort();
}, []);
// WebSockets
useEffect(() => {
const ws = new WebSocket(url);
return () => ws.close();
}, []);
// Observers
useEffect(() => {
const observer = new IntersectionObserver(() => {});
observer.observe(element);
return () => observer.disconnect();
}, []);
Tested on React 19.0.2, Chrome 131, Node.js 22.x, macOS & Windows