Memory Leaks in React: How I Found Mine with Chrome DevTools

The dashboard page was eating 50MB of memory every time a user navigated to it and back. After three round trips, the tab was over 400MB and the UI was lagging. The component was unmounting, state was resetting, but something held onto memory and refused to let go.
Spotting the Leak
Open Chrome DevTools, go to the Memory tab, and select Allocation instrumentation on timeline. Navigate to the suspect page, navigate away, repeat 3-4 times, then stop recording.
If memory climbs with each cycle and never drops back, you have a leak. Blue bars that accumulate instead of disappearing on navigation are your visual signal.
Heap Snapshots: Finding What’s Leaking
Take three snapshots: before visiting the page, after visiting and leaving, then after visiting and leaving again. Compare Snapshot 3 against Snapshot 1, sorted by Size Delta. In my case: thousands of Detached HTMLDivElement nodes and growing EventListener counts.
The Culprit: Forgotten Subscriptions
The dashboard had a WebSocket connection. The cleanup looked correct but had a subtle bug:
// THE BUG
useEffect(() => {
const ws = new WebSocket('wss://api.example.com/stream');
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
setMetrics(prev => [...prev, data]);
};
return () => {
ws.close(); // This looks right...
};
}, []);
The onmessage closure captured setMetrics. Even after ws.close(), the WebSocket wasn’t garbage collected because the closure held a reference chain: event handler -> state setter -> component fiber -> DOM nodes.
// FIXED
useEffect(() => {
const ws = new WebSocket('wss://api.example.com/stream');
const handleMessage = (event: MessageEvent) => {
const data = JSON.parse(event.data);
setMetrics(prev => [...prev, data]);
};
ws.addEventListener('message', handleMessage);
return () => {
ws.removeEventListener('message', handleMessage);
ws.close();
};
}, []);
Explicitly removing the listener before closing breaks the reference chain.
The Second Leak: setInterval Without Cleanup
// LEAK
useEffect(() => {
if (!wsConnected) {
setInterval(() => {
fetchMetrics().then(data => setMetrics(data));
}, 5000);
}
}, [wsConnected]);
No cleanup. Every time wsConnected changed, a new interval was created and the old one kept running.
// FIXED
useEffect(() => {
if (!wsConnected) {
const intervalId = setInterval(() => {
fetchMetrics().then(data => setMetrics(data));
}, 5000);
return () => clearInterval(intervalId);
}
}, [wsConnected]);
The Third Leak: Closures Over Stale State
A ResizeObserver callback captured component state in its closure:
// LEAK — closure captures initial 'metrics', never updates
useEffect(() => {
const observer = new ResizeObserver((entries) => {
if (metrics.length > 0) {
recalculateLayout(entries, metrics);
}
});
observer.observe(containerRef.current);
return () => observer.disconnect();
}, []);
The fix: use a ref for values that callbacks need without creating new closures:
const metricsRef = useRef(metrics);
metricsRef.current = metrics;
useEffect(() => {
const observer = new ResizeObserver((entries) => {
if (metricsRef.current.length > 0) {
recalculateLayout(entries, metricsRef.current);
}
});
if (containerRef.current) {
observer.observe(containerRef.current);
}
return () => observer.disconnect();
}, []);
Prevention Checklist
For every useEffect:
- Does it create a subscription, listener, observer, or interval? Cleanup must tear it down.
- Does cleanup explicitly remove event listeners, not just close/disconnect?
- Does the closure capture changing state? Use a ref instead.
- Is there an async operation that might resolve after unmount? Use an AbortController.
Memory leaks in React almost always come from effects that set things up but don’t fully tear them down. Chrome DevTools makes them findable. Disciplined cleanup in every useEffect prevents them.
Written by
Adrian Saycon
A developer with a passion for emerging technologies, Adrian Saycon focuses on transforming the latest tech trends into great, functional products.


