Three React useEffect Cleanups You're Probably Skipping — and the Memory Leaks They Cause
I tracked down a React memory leak — the culprits were these 3 overlooked cleanup functions
Pages get slower the longer they're open, and the memory curve in Task Manager only goes up. This kind of problem is hard to catch during development — refreshing locally a few times reveals nothing; it only shows up after the page has been running in the background for dozens of minutes.
I used Chrome DevTools' Memory panel to compare several heap snapshots and finally identified three culprits, all following the same pattern: a listener was registered, but there was no corresponding cleanup function.
How I found it: heap snapshot comparison
Open DevTools → Memory → select Heap snapshot. Take a snapshot after performing a few normal operations on the page, trigger the suspected actions a few times (like opening and closing a modal, switching tabs a few times), then take another snapshot. Use the Comparison view to look at objects that are New (newly created and not released) between the two snapshots.
If the same type of object (like Detached HTMLDivElement or instances of a custom class) keeps growing without dropping back down, you can basically confirm a memory leak. What I saw this time was that the number of (closure) and Detached nodes grew linearly with the number of "open modal → close modal" operations — theoretically, they should have been zeroed out after each close, but they weren't.
Culprit 1: window event listeners not removed
// ❌ Listener added, forgotten
function ResizablePanel() {
const [width, setWidth] = useState(300);
useEffect(() => {
const handleResize = () => {
setWidth(window.innerWidth * 0.3);
};
window.addEventListener('resize', handleResize);
// No cleanup function returned!
}, []);
return <div style={{ width }}>Panel Content</div>;
}
After this component unmounts, the handleResize function is still held by window. And this function's closure references setWidth — which indirectly references the component instance. As long as window is alive (and it always is), this function, this closure, and the indirectly associated component-related objects can never be garbage collected.
If this component is frequently mounted/unmounted (like a sidebar panel that can be opened and closed), each mount adds a new resize listener, and unmounting doesn't clean it up — the leak accumulates linearly with user actions.
// ✅ useEffect returns a cleanup function
function ResizablePanel() {
const [width, setWidth] = useState(300);
useEffect(() => {
const handleResize = () => {
setWidth(window.innerWidth * 0.3);
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
return <div style={{ width }}>Panel Content</div>;
}
Principle: Every addEventListener must have a corresponding removeEventListener, written in the useEffect cleanup function. There are no exceptions to this rule.
Culprit 2: WebSocket/subscription connections not closed
// ❌ Connection established, never disconnected
function LiveChart({ symbol }) {
const [price, setPrice] = useState(0);
useEffect(() => {
const ws = new WebSocket(`wss://api.example.com/ticker/${symbol}`);
ws.onmessage = (event) => {
setPrice(JSON.parse(event.data).price);
};
// ws is never closed anywhere!
}, [symbol]);
return <div>{symbol}: {price}</div>;
}
This problem is more subtle and more severe than ordinary event listeners. Every time symbol changes, useEffect re-runs, creating a new WebSocket connection — the old connection is never close()d, so it never disconnects.
If a user switches between 10 different stocks/coins, there will be 10 active WebSocket connections running in the background simultaneously, continuously receiving data, continuously triggering onmessage callbacks, and continuously calling closures associated with components that "should be dead but aren't." This isn't just a memory leak; it's also real network resource waste and unnecessary server pressure.
// ✅ Close connection when dependency changes or component unmounts
function LiveChart({ symbol }) {
const [price, setPrice] = useState(0);
useEffect(() => {
const ws = new WebSocket(`wss://api.example.com/ticker/${symbol}`);
ws.onmessage = (event) => {
setPrice(JSON.parse(event.data).price);
};
return () => {
ws.close();
};
}, [symbol]);
return <div>{symbol}: {price}</div>;
}
The same pattern applies to EventSource (SSE), subscribe() functions returned by third-party libraries (like RxJS Observables, Firebase's onSnapshot) — whenever you "register a channel that continuously receives data," there must be corresponding code to "deregister that channel."
Principle: Use close() for WebSocket, close() for EventSource, and any unsubscribe function returned by a subscribe must be called. These resources do not automatically disconnect when the component unmounts; the JS engine doesn't know about your React component lifecycle.
Culprit 3: third-party library instances not calling destroy/dispose
// ❌ Chart instance created, not destroyed on unmount
function SalesChart({ data }) {
const chartRef = useRef(null);
useEffect(() => {
const chart = echarts.init(chartRef.current);
chart.setOption({
series: [{ type: 'line', data }],
});
// chart instance is never disposed!
}, [data]);
return <div ref={chartRef} style={{ height: 400 }} />;
}
This was the largest leak in my investigation. Visualization libraries like ECharts, Chart.js, and map libraries (Leaflet, AMap) typically maintain their own Canvas contexts, event systems, and internal state trees internally — these are all objects independent of the React lifecycle.
After the component unmounts, the DOM node is removed by React, but the chart instance returned by echarts.init still lives in memory. The Canvas it holds internally, off-screen rendering caches, and event listeners bound to the DOM node are never automatically released. These instances are usually much larger than ordinary JS objects — a single chart instance can be several MB. Switching back and forth between 10 pages can cause memory to balloon by dozens of MB.
// ✅ Call the library's provided destruction method on unmount
function SalesChart({ data }) {
const chartRef = useRef(null);
useEffect(() => {
const chart = echarts.init(chartRef.current);
chart.setOption({
series: [{ type: 'line', data }],
});
return () => {
chart.dispose();
};
}, [data]);
return <div ref={chartRef} style={{ height: 400 }} />;
}
Principle: For any "instance returned by a third-party library's init/create method," first check the documentation for a destroy/dispose/unmount method. If it exists, it must be called in the cleanup function — this is a signal from the library author that "this instance needs to be manually released."
Summary of the investigation approach
The common pattern among these three culprits can be summed up in one sentence: For every "registration" operation, find its corresponding "deregistration" operation.
| Registration Operation | Corresponding Deregistration Operation |
|---|---|
addEventListener |
removeEventListener |
new WebSocket() |
.close() |
new EventSource() |
.close() |
observer.subscribe() |
Call the returned unsubscribe function |
thirdPartyLib.init() |
Check docs for destroy/dispose |
setTimeout/setInterval |
clearTimeout/clearInterval |
The most effective way to investigate memory leaks isn't to scrutinize code line by line, but to:
- Open the Memory panel, take a heap snapshot before and after an operation
- Use the Comparison view to find object types that keep growing without dropping back down
- Trace back from the object type to find which component is creating it
- Check that component's
useEffectto see if the creation operation has a corresponding cleanup function
Memory leaks won't crash your project immediately
This is also why they're easier to overlook than logic bugs — they won't cause test cases to fail, won't conspicuously error out in Code Review, they'll just make pages that run longer online slower and slower, until a user reports "it's unbearably slow after being open all day," and you finally remember to check.
The useEffect return cleanup function mechanism was designed by the React team from the very beginning. The real problem is never "not knowing how to clean up," but rather not realizing "this needs cleanup" when writing the code.
In your project, is there a useEffect that creates something but has never had a corresponding cleanup function written for it?