Memory Leaks
Why memory leaks happen and how to hunt them?
Memory Leaks in React
Master React memory leaks with free flashcards and spaced repetition practice. This lesson covers identifying memory leaks, preventing them with cleanup functions, and debugging techniquesβessential concepts for building performant React applications that scale.
Welcome π
Memory leaks are one of the most insidious problems in React applications. They occur silently in the background, gradually degrading performance until your app becomes sluggish or crashes. Unlike syntax errors that fail immediately, memory leaks accumulate over time, making them challenging to detect and fix.
In this comprehensive lesson, you'll learn:
- π What memory leaks are and how they manifest in React
- π§Ή Essential cleanup patterns for hooks and lifecycle methods
- β οΈ Common scenarios that cause leaks (async operations, event listeners, timers)
- π οΈ Tools and techniques for detecting and debugging memory leaks
- π‘ Best practices for preventing leaks before they occur
Core Concepts π»
What is a Memory Leak?
Memory leaks occur when your application allocates memory but fails to release it after it's no longer needed. In React, this typically happens when:
- Components unmount but leave behind active subscriptions, timers, or event listeners
- Asynchronous operations complete after a component has unmounted
- References to unmounted components are retained by closures or global objects
βββββββββββββββββββββββββββββββββββββββββββ β MEMORY LEAK LIFECYCLE β βββββββββββββββββββββββββββββββββββββββββββ€ β β β π¦ Component Mounts β β β β β π Creates subscription/timer β β β β β β Component Unmounts β β β β β β οΈ Subscription still active! β β β β β πΎ Memory not released β β β β β π Heap grows over time β β β βββββββββββββββββββββββββββββββββββββββββββ
The Unmounted Component Problem
The most common memory leak pattern in React involves state updates on unmounted components. Consider this scenario:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(data => {
setUser(data); // β οΈ What if component unmounts during fetch?
});
}, [userId]);
return <div>{user?.name}</div>;
}
If the user navigates away before fetchUser completes, React will call setUser on an unmounted component, causing a memory leak and this warning:
"Warning: Can't perform a React state update on an unmounted component."
Memory Leak Symptoms π
How do you know if your app has memory leaks?
| Symptom | Description | When It Appears |
|---|---|---|
| π Slowdown | App gets progressively slower | After repeated navigation |
| πΎ Memory Growth | Heap size increases continuously | Visible in DevTools Memory profiler |
| β οΈ Console Warnings | "Can't perform state update..." messages | When navigating between routes |
| π₯ Browser Crash | Tab becomes unresponsive | After extended usage |
| π Stale Closures | Old data appears unexpectedly | When components re-render |
Cleanup Functions: Your First Defense π§Ή
The cleanup function (also called the "teardown" or "destructor") is returned from useEffect and runs when:
- The component unmounts
- Before the effect runs again (when dependencies change)
useEffect(() => {
// Setup code
const subscription = dataSource.subscribe();
// Cleanup function
return () => {
subscription.unsubscribe();
};
}, [dependency]);
Key principle: Every resource your effect creates should be cleaned up. Think of it as "undoing" everything the effect did.
Common Memory Leak Scenarios π―
1. Async Operations (Fetch, Promises)
The Problem: Network requests don't automatically cancel when components unmount.
β Leaky Code:
function ArticleViewer({ articleId }) {
const [article, setArticle] = useState(null);
useEffect(() => {
fetch(`/api/articles/${articleId}`)
.then(res => res.json())
.then(data => setArticle(data)); // β οΈ Leak if unmounted!
}, [articleId]);
return <article>{article?.content}</article>;
}
β Fixed with Cleanup Flag:
function ArticleViewer({ articleId }) {
const [article, setArticle] = useState(null);
useEffect(() => {
let isMounted = true;
fetch(`/api/articles/${articleId}`)
.then(res => res.json())
.then(data => {
if (isMounted) {
setArticle(data); // β
Only update if still mounted
}
});
return () => {
isMounted = false; // π§Ή Cleanup: mark as unmounted
};
}, [articleId]);
return <article>{article?.content}</article>;
}
β Better: AbortController (Modern Approach):
function ArticleViewer({ articleId }) {
const [article, setArticle] = useState(null);
useEffect(() => {
const controller = new AbortController();
fetch(`/api/articles/${articleId}`, {
signal: controller.signal
})
.then(res => res.json())
.then(data => setArticle(data))
.catch(err => {
if (err.name !== 'AbortError') {
console.error('Fetch error:', err);
}
});
return () => {
controller.abort(); // π§Ή Actually cancel the request
};
}, [articleId]);
return <article>{article?.content}</article>;
}
π‘ Tip: AbortController is preferred because it actually cancels the network request, saving bandwidth. The boolean flag approach only prevents the state update but the request still completes.
2. Event Listeners π§
The Problem: Event listeners registered on window, document, or DOM elements persist after component unmount.
β Leaky Code:
function ScrollTracker() {
const [scrollY, setScrollY] = useState(0);
useEffect(() => {
const handleScroll = () => {
setScrollY(window.scrollY);
};
window.addEventListener('scroll', handleScroll);
// β οΈ Never removed!
}, []);
return <div>Scroll position: {scrollY}px</div>;
}
β Fixed with Cleanup:
function ScrollTracker() {
const [scrollY, setScrollY] = useState(0);
useEffect(() => {
const handleScroll = () => {
setScrollY(window.scrollY);
};
window.addEventListener('scroll', handleScroll);
return () => {
window.removeEventListener('scroll', handleScroll); // π§Ή
};
}, []);
return <div>Scroll position: {scrollY}px</div>;
}
π§ Mnemonic: "Every add needs a remove" - if you add a listener, you must remove it.
3. Timers and Intervals β°
The Problem: setTimeout and setInterval continue running even after unmount.
β Leaky Code:
function CountdownTimer({ seconds }) {
const [timeLeft, setTimeLeft] = useState(seconds);
useEffect(() => {
const timer = setInterval(() => {
setTimeLeft(prev => prev - 1); // β οΈ Runs forever!
}, 1000);
}, []);
return <div>Time left: {timeLeft}s</div>;
}
β Fixed with Cleanup:
function CountdownTimer({ seconds }) {
const [timeLeft, setTimeLeft] = useState(seconds);
useEffect(() => {
const timer = setInterval(() => {
setTimeLeft(prev => prev - 1);
}, 1000);
return () => {
clearInterval(timer); // π§Ή Clear the interval
};
}, []);
return <div>Time left: {timeLeft}s</div>;
}
π‘ Tip: Store the timer ID returned by setTimeout/setInterval so you can clear it later.
4. Subscriptions (WebSockets, Observable Libraries) π‘
The Problem: External subscriptions (RxJS, WebSocket, Firebase, etc.) don't auto-cleanup.
β Leaky Code:
function LiveChat({ roomId }) {
const [messages, setMessages] = useState([]);
useEffect(() => {
const socket = io('https://chat.example.com');
socket.on('message', (msg) => {
setMessages(prev => [...prev, msg]);
});
socket.emit('join', roomId);
// β οΈ Socket never closed!
}, [roomId]);
return <MessageList messages={messages} />;
}
β Fixed with Cleanup:
function LiveChat({ roomId }) {
const [messages, setMessages] = useState([]);
useEffect(() => {
const socket = io('https://chat.example.com');
const handleMessage = (msg) => {
setMessages(prev => [...prev, msg]);
};
socket.on('message', handleMessage);
socket.emit('join', roomId);
return () => {
socket.off('message', handleMessage); // π§Ή Remove listener
socket.disconnect(); // π§Ή Close connection
};
}, [roomId]);
return <MessageList messages={messages} />;
}
5. Third-Party Libraries π
The Problem: Libraries that create instances (maps, charts, editors) often require manual cleanup.
β Pattern for Third-Party Cleanup:
function MapComponent({ center }) {
const mapRef = useRef(null);
const mapInstanceRef = useRef(null);
useEffect(() => {
// Initialize the map
const map = new GoogleMap(mapRef.current, {
center: center,
zoom: 10
});
mapInstanceRef.current = map;
return () => {
// π§Ή Cleanup: destroy the map instance
if (mapInstanceRef.current) {
mapInstanceRef.current.destroy();
mapInstanceRef.current = null;
}
};
}, [center]);
return <div ref={mapRef} style={{ width: '100%', height: '400px' }} />;
}
π‘ Best Practice: Always check the library's documentation for cleanup methods (common names: destroy(), dispose(), cleanup(), unmount()).
Class Component Memory Leaks ποΈ
While hooks are more common in modern React, class components have their own patterns:
β Leaky Class Component:
class DataFetcher extends React.Component {
state = { data: null };
componentDidMount() {
fetch('/api/data')
.then(res => res.json())
.then(data => this.setState({ data })); // β οΈ Leak if unmounted!
}
render() {
return <div>{this.state.data}</div>;
}
}
β Fixed with componentWillUnmount:
class DataFetcher extends React.Component {
state = { data: null };
isMounted = false;
componentDidMount() {
this.isMounted = true;
fetch('/api/data')
.then(res => res.json())
.then(data => {
if (this.isMounted) {
this.setState({ data });
}
});
}
componentWillUnmount() {
this.isMounted = false; // π§Ή Cleanup flag
}
render() {
return <div>{this.state.data}</div>;
}
}
Advanced Patterns π
Custom Hook for Safe Async
Create a reusable hook to prevent async memory leaks:
function useSafeAsync() {
const isMountedRef = useRef(true);
useEffect(() => {
return () => {
isMountedRef.current = false;
};
}, []);
const safeSetState = useCallback((callback) => {
if (isMountedRef.current) {
callback();
}
}, []);
return safeSetState;
}
// Usage
function MyComponent() {
const [data, setData] = useState(null);
const safeSetState = useSafeAsync();
useEffect(() => {
fetch('/api/data')
.then(res => res.json())
.then(result => {
safeSetState(() => setData(result)); // β
Safe!
});
}, [safeSetState]);
return <div>{data}</div>;
}
useRef for Mutable Values
useRef is perfect for storing values that shouldn't trigger re-renders but need to persist:
function VideoPlayer({ videoId }) {
const playerRef = useRef(null);
useEffect(() => {
// Create player instance
playerRef.current = new YouTubePlayer('player', {
videoId: videoId
});
return () => {
// π§Ή Cleanup: destroy player
if (playerRef.current) {
playerRef.current.destroy();
playerRef.current = null;
}
};
}, [videoId]);
return <div id="player"></div>;
}
Debouncing and Throttling β±οΈ
Debounced/throttled functions can cause leaks if not properly cleaned:
import { debounce } from 'lodash';
function SearchBox() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
useEffect(() => {
const debouncedSearch = debounce(async (searchQuery) => {
const data = await fetch(`/api/search?q=${searchQuery}`);
const json = await data.json();
setResults(json);
}, 300);
if (query) {
debouncedSearch(query);
}
return () => {
debouncedSearch.cancel(); // π§Ή Cancel pending debounced calls
};
}, [query]);
return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} />
<ResultsList results={results} />
</div>
);
}
Debugging Memory Leaks π§
Chrome DevTools Memory Profiler
βββββββββββββββββββββββββββββββββββββββββββ β MEMORY LEAK DEBUGGING PROCESS β βββββββββββββββββββββββββββββββββββββββββββ€ β β β 1οΈβ£ Open Chrome DevTools β Memory β β β β β 2οΈβ£ Take Heap Snapshot (baseline) β β β β β 3οΈβ£ Perform actions (navigate, etc.) β β β β β 4οΈβ£ Take another Heap Snapshot β β β β β 5οΈβ£ Compare snapshots β β β β β 6οΈβ£ Look for: β β β’ Detached DOM nodes β β β’ Event listeners β β β’ Large arrays/objects β β β βββββββββββββββββββββββββββββββββββββββββββ
Steps to Profile:
- Record Heap Snapshot: Capture initial memory state
- Perform Actions: Navigate between routes, trigger operations
- Force Garbage Collection: Click the trash icon in DevTools
- Take Second Snapshot: Capture new memory state
- Compare: Look for objects that weren't garbage collected
React DevTools Profiler
The Profiler tab helps identify components that re-render unnecessarily, which can indicate memory issues:
import { Profiler } from 'react';
function onRenderCallback(id, phase, actualDuration) {
console.log(`${id} (${phase}) took ${actualDuration}ms`);
}
function App() {
return (
<Profiler id="App" onRender={onRenderCallback}>
<YourComponents />
</Profiler>
);
}
Why Did You Render Library
Install @welldone-software/why-did-you-render to detect unnecessary re-renders:
import React from 'react';
if (process.env.NODE_ENV === 'development') {
const whyDidYouRender = require('@welldone-software/why-did-you-render');
whyDidYouRender(React, {
trackAllPureComponents: true,
});
}
Console Warning Detection
Add this to catch state update warnings in development:
// In your main entry file
if (process.env.NODE_ENV === 'development') {
const originalError = console.error;
console.error = (...args) => {
if (typeof args[0] === 'string' && args[0].includes('unmounted component')) {
console.trace('Memory leak detected!'); // Shows stack trace
}
originalError(...args);
};
}
Common Mistakes β οΈ
β Mistake 1: Forgetting to Return Cleanup Function
// Wrong - no cleanup
useEffect(() => {
const timer = setInterval(() => console.log('tick'), 1000);
}, []);
// Right - cleanup included
useEffect(() => {
const timer = setInterval(() => console.log('tick'), 1000);
return () => clearInterval(timer);
}, []);
β Mistake 2: Cleanup Function Doesn't Mirror Setup
// Wrong - mismatched cleanup
useEffect(() => {
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('scroll', handleResize); // β Wrong event!
};
}, []);
// Right - exact mirror
useEffect(() => {
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize); // β
Matches!
};
}, []);
β Mistake 3: Creating New Function References
// Wrong - creates new function each render
useEffect(() => {
window.addEventListener('scroll', () => console.log('scroll'));
return () => {
window.removeEventListener('scroll', () => console.log('scroll')); // β Different function!
};
}, []);
// Right - same function reference
useEffect(() => {
const handleScroll = () => console.log('scroll');
window.addEventListener('scroll', handleScroll);
return () => {
window.removeEventListener('scroll', handleScroll); // β
Same reference!
};
}, []);
β Mistake 4: Not Cleaning Up All Resources
// Wrong - only cleans up one thing
useEffect(() => {
const timer = setInterval(() => {}, 1000);
const socket = io('ws://example.com');
const listener = window.addEventListener('resize', handleResize);
return () => {
clearInterval(timer); // β Forgot socket and listener!
};
}, []);
// Right - cleans up everything
useEffect(() => {
const timer = setInterval(() => {}, 1000);
const socket = io('ws://example.com');
const handleResize = () => {};
window.addEventListener('resize', handleResize);
return () => {
clearInterval(timer); // β
socket.disconnect(); // β
window.removeEventListener('resize', handleResize); // β
};
}, []);
β Mistake 5: Async Operations Without Cancellation
// Wrong - no cancellation
useEffect(() => {
fetch('/api/data')
.then(res => res.json())
.then(data => setState(data)); // β οΈ Runs even after unmount!
}, []);
// Right - with AbortController
useEffect(() => {
const controller = new AbortController();
fetch('/api/data', { signal: controller.signal })
.then(res => res.json())
.then(data => setState(data))
.catch(err => {
if (err.name !== 'AbortError') console.error(err);
});
return () => controller.abort();
}, []);
β Mistake 6: Conditional Cleanup
// Wrong - conditional cleanup creates inconsistency
useEffect(() => {
const timer = setInterval(() => {}, 1000);
if (someCondition) {
return () => clearInterval(timer); // β Only sometimes cleans up!
}
}, [someCondition]);
// Right - always return cleanup
useEffect(() => {
if (someCondition) {
const timer = setInterval(() => {}, 1000);
return () => clearInterval(timer); // β
Cleanup matches setup
}
}, [someCondition]);
Prevention Checklist β
Use this checklist when writing effects:
- Does your effect create a subscription? β Add unsubscribe in cleanup
- Does it add event listeners? β Remove them in cleanup
- Does it start timers/intervals? β Clear them in cleanup
- Does it make async calls? β Use AbortController or isMounted flag
- Does it instantiate third-party libraries? β Call their cleanup methods
- Does the cleanup function mirror the setup exactly?
- Have you tested unmounting the component?
- Are you using the same function reference for add/remove?
Key Takeaways π―
Always clean up side effects - Every resource created in
useEffectmust be released in the cleanup functionUse AbortController for fetch - Modern browsers support request cancellation, use it
The cleanup function is your friend - Think of it as the "undo" for everything your effect does
Test component unmounting - Navigate away from components during development to catch leaks early
Watch for console warnings - React warns you about state updates on unmounted components
Profile regularly - Use Chrome DevTools Memory profiler to catch leaks before production
Same reference for listeners - Store event handler functions so you can remove the exact same reference
Custom hooks for reusability - Extract cleanup patterns into reusable hooks
Class components need componentWillUnmount - Clean up in this lifecycle method
Third-party libraries need manual cleanup - Always check documentation for cleanup methods
π Quick Reference: Memory Leak Prevention
| Resource Type | Setup | Cleanup |
|---|---|---|
| Timer | setTimeout(fn, ms) | clearTimeout(id) |
| Interval | setInterval(fn, ms) | clearInterval(id) |
| Event Listener | addEventListener(e, fn) | removeEventListener(e, fn) |
| Fetch Request | fetch(url, {signal}) | controller.abort() |
| WebSocket | new WebSocket(url) | socket.close() |
| Observable | observable.subscribe() | subscription.unsubscribe() |
| Animation | requestAnimationFrame(fn) | cancelAnimationFrame(id) |
π Further Study
- React Documentation - Effects with Cleanup: https://react.dev/learn/synchronizing-with-effects#how-to-handle-the-effect-firing-twice-in-development
- Chrome DevTools Memory Profiling: https://developer.chrome.com/docs/devtools/memory-problems/
- Fixing Memory Leaks in React (Article): https://kentcdodds.com/blog/fix-the-slow-render-before-you-fix-the-re-render
π Congratulations! You now understand how to identify, prevent, and fix memory leaks in React applications. Practice implementing cleanup functions in your projects, and use profiling tools regularly to keep your apps performant!