Preventing and Debugging Memory Leaks in React

In Brief
Start with effects that subscribe, schedule work, or keep references alive after a component unmounts. Event listeners, timers, requests, observers, and external stores are more common sources of React memory leaks than rendering itself. The fix is usually clear ownership and cleanup, not trying to outsmart garbage collection.
Memory leaks in React are not always obvious, but they can cause performance problems that get worse over time. A leak happens when application code or an external resource keeps objects reachable after they are no longer needed, so the JavaScript engine cannot collect them. This leads to increased memory usage, making applications feel slower or less responsive.
The most frustrating part is that memory leaks often go unnoticed until an application has been running for a while. Fortunately, we can prevent them by following best practices, and when they do occur, we have tools to debug and fix them. In this article, I explore common causes of memory leaks, how to avoid them, and ways to track them down in a React application.
What Causes Memory Leaks in React?
The JavaScript engine, not React, performs garbage collection, and it can reclaim an object only after that object becomes unreachable. React runs effect cleanup when dependencies change and after a component is removed. Application code must use that cleanup to remove listeners, clear timers, unsubscribe, abort work where useful, and close external resources.
Common Causes of Memory Leaks
- Unsubscribed Event Listeners – A long‑lived event target can retain its listener and anything captured by the listener's closure until the listener is removed.
- Uncleared Timers and Intervals – A pending timer retains its callback and captured references until it fires or is cleared, whilst an interval continues until it is cleared.
- Unclosed Subscriptions and External Resources – WebSockets, observers, polling, and similar resources remain active until application code closes or unsubscribes from them.
- Late Async Results – A promise resolving after unmount is not automatically a memory leak, but stale work can waste resources or race with newer work. Cancel it, or ignore its result, when the operation allows that.
- Holding Large Objects in State – State and its contents remain reachable whilst their owning component state remains reachable, so keeping unnecessary data there increases memory use.
So, let's take a look at how we can prevent these issues in our React applications.
Preventing Memory Leaks in React
We can avoid most of these problems by making each effect's cleanup mirror its setup. React invokes the cleanup, but application code owns the actual removal, cancellation, disconnection, or release.
Cleaning up Event Listeners
Adding event listeners inside useEffect is common, but failing to remove them can lead to memory leaks.
Problem: Event Listener Remains After Unmounting
useEffect(() => { window.addEventListener("resize", () => console.log("Resized"));}, []);This event listener will remain active even after the component has been unmounted and gone.
Solution: Remove the event listener in useEffect cleanup
useEffect(() => { const handleResize = () => console.log("Resized"); window.addEventListener("resize", handleResize); return () => { window.removeEventListener("resize", handleResize); };}, []);React invokes the function returned by useEffect when the component unmounts, allowing our application code to remove the event listener. React also runs the old cleanup before rerunning an effect whose dependencies have changed.
Clearing Timers and Intervals
Timers continue running unless explicitly cleared.
Problem: Timer Persists After Unmounting
useEffect(() => { setInterval(() => console.log("Still running"), 1000);}, []);Even if the component unmounts, this interval keeps running indefinitely.
Solution: Clear the Interval
You'll start to see a pattern with these solutions, I'm sure. The answer is to make sure that we clear the interval again in the hook's return. Like this:
useEffect(() => { const interval = setInterval(() => console.log("Still running"), 1000); return () => { clearInterval(interval); };}, []);Cancelling API Requests
A request that resolves after a component unmounts is not, by itself, proof of a memory leak. It can still waste work or let an older result race with a newer one, so cancelling work that is no longer needed remains useful.
Problem: Fetch Request Continues After Unmounting
useEffect(() => { fetch("/api/data") .then((res) => res.json()) .then((data) => console.log(data));}, []);If the request is slow, its callback may still run after the component is removed, even though the result is no longer useful.
Solution: Use AbortController to cancel pending requests
useEffect(() => { const controller = new AbortController(); fetch("/api/data", { signal: controller.signal }) .then((res) => res.json()) .then((data) => console.log(data)) .catch((err) => { if (err.name !== "AbortError") { console.error(err); } }); return () => { controller.abort(); };}, []);By aborting the controller, we cancel work that is no longer needed and prevent its follow‑up callback from using a stale result. That is external‑resource cleanup, not garbage collection.
Unsubscribing from WebSockets and Event Streams
If a component opens a WebSocket or subscribes to an external event stream, the connection and its callbacks remain active until the application closes or unsubscribes from them. Those callbacks may retain other values from the effect's closure.
Problem: WebSocket Stays Open
useEffect(() => { const socket = new WebSocket("wss://example.com"); socket.onmessage = (event) => console.log(event.data);}, []);The WebSocket remains open, keeping its connection and callback active after the component has been removed.
Solution: Close the Connection on Unmount
useEffect(() => { const socket = new WebSocket("wss://example.com"); socket.onmessage = (event) => console.log(event.data); return () => { socket.close(); };}, []);Debugging Memory Leaks in React
If memory usage keeps increasing after repeated interactions and does not settle after garbage collection, there may be a leak. A rising graph is a lead rather than proof, so compare snapshots and inspect what is retaining the objects. Here's some rough guidance on how to track it down.
Using Chrome DevTools
- Open DevTools (
F12orCmd + Option + I). - Go to the Performance tab and record whilst interacting with the app.
- Look at the JS Heap Size graph. If memory usage keeps growing without dropping, there may be a leak.
Tracking Detached Elements in DevTools
- Open the Memory tab in Chrome DevTools.
- Take a snapshot before and after navigating away from a component.
- If elements from the unmounted component are still present, they are not being garbage collected.
Preventing State Updates on Unmounted Components
When an API cannot be cancelled, a mounted‑state guard can stop a stale result from affecting the interface after unmount. The guard does not cancel the request or perform garbage collection.
const isMounted = useRef(true);useEffect(() => { isMounted.current = true; return () => { isMounted.current = false; };}, []);const fetchData = async () => { const res = await fetch("/api/data"); const data = await res.json(); if (isMounted.current) { console.log(data); }};This prevents the follow‑up work from running after unmount. Prefer cancelling the external work when its API supports cancellation, because the guard alone does not release that resource.
Wrapping Up
Memory leaks can cause slowdowns and unexpected behaviour in React applications, but they are avoidable. The JavaScript engine collects unreachable objects; our part is to remove application references and clean up external resources when an effect no longer needs them. Chrome DevTools can then help us identify what still retains an object when memory does not settle.
Key Takeaways
- Memory leaks happen when references keep objects reachable after they are no longer needed.
- Listeners, timers, subscriptions, and external resources can retain callbacks or remain active until application code cleans them up.
useEffectcleanup is where application code should reverse the external setup performed by that effect.Chrome DevTools and memory snapshots
help detect leaks in running applications.AbortControllercan cancel supported work; a mounted‑state guard can only ignore a stale result, and neither mechanism is garbage collection.
By keeping memory usage under control, we can make our React applications faster, smoother, and more reliable over time.