Finding and Fixing Memory Leaks in React
Recognize the nine most common sources of memory leaks in React applications and apply the correct cleanup pattern for each, so that components release every resource they acquire.
A memory leak in a React app almost never looks like a leak. It looks like a warning in the console about setting state on an unmounted component, a browser tab that slowly grows heavier the longer a single-page app stays open, or a WebSocket connection that silently keeps firing callbacks for a screen the user left five minutes ago. The root cause is always the same shape: something was subscribed to, allocated, or registered, and nothing ever unsubscribed, deallocated, or unregistered it. React's component lifecycle gives you exactly one place to fix this — the cleanup function returned from useEffect — and this lesson walks through every category of leak you will meet in production code, with the cleanup pattern that closes it.
Why React makes leaks easy to introduce
Function components run their body on every render, and any side effect you create outside of useEffect — an event listener attached directly in the render body, a timer started at module scope, a WebSocket opened in a callback — is disconnected from React's lifecycle entirely. React has no way to know that resource exists, so it has no way to clean it up when the component unmounts.
Even inside useEffect, a leak happens the moment you create a subscription but skip the return statement. useEffect treats its cleanup function as optional, and JavaScript will not warn you that you forgot one — the code runs fine, the leak just accumulates silently until memory pressure or a stale-callback bug makes it visible.
The mental model that prevents almost every leak in this lesson is: for every 'start listening / start timer / start connection' call, write the matching 'stop listening / clear timer / close connection' call in the same breath, inside the cleanup function, before you write anything else.
The nine leak sources and their fixes
Each of the categories below follows the same shape: acquire a resource when the effect runs, release it in the effect's cleanup function. The examples section that follows shows working code for the highest-risk ones — event listeners, timers, subscriptions, WebSockets, object URLs, and AbortController — in full.
• Event listeners — window.addEventListener / document.addEventListener calls that are never paired with removeEventListener keep the component's closure (and everything it references) alive for the lifetime of the page, even after the component unmounts. • Timers — setInterval and setTimeout callbacks captured in a closure keep referencing component state; an uncleared setInterval runs forever and can call setState on an unmounted component. • Subscriptions — observables, event emitters, Redux store subscriptions, or any pub/sub library's .subscribe() call needs its matching .unsubscribe() in cleanup, or the emitter holds a permanent reference to the callback and everything it closes over. • WebSockets — an open WebSocket keeps a live network connection and an event-listener graph alive; failing to call .close() on unmount leaves the socket open, still receiving messages and still holding memory, for as long as the browser tab lives. • Object URLs — URL.createObjectURL(blob) allocates a reference that keeps the underlying Blob or File data in memory until you explicitly call URL.revokeObjectURL(url); this is one of the few leaks that is not tied to the component lifecycle at all, so it is easy to forget entirely. • AbortController — a fetch request that is still in flight when the component unmounts will eventually resolve and try to call setState, producing both a leak and a runtime warning; an AbortController lets you cancel the request itself, not just ignore its result. • Detached DOM references — storing a DOM node in a ref, a module-level variable, or a closure and continuing to reference it after React has removed that node from the tree keeps the entire detached subtree (and its listeners) in memory; React's own DOM node is released once nothing outside the tree references it. • Large caches — an in-memory Map or object used as a cache (memoized computations, fetched-data-by-id, image blobs) that never evicts old entries grows without bound; a leak here is architectural, not a missing cleanup call, and needs an eviction policy (LRU, TTL, or a max-size cap). • Effect cleanup itself — the most common bug is not forgetting cleanup syntax, but forgetting that cleanup runs before *every* re-run of the effect, not just on unmount; if your effect's dependency array changes on every render (e.g. you depend on a new object or function literal), cleanup and re-setup thrash on every render, which is functionally a leak because old listeners can overlap with new ones during the thrash.
The cleanup-function shape
The returned function runs before the effect re-runs, and again when the component unmounts — so it must fully reverse whatever the effect body did. Anything acquired inside the effect body must be released inside the cleanup closure that captures the same reference — never re-derive it, or you will release the wrong instance.
useEffect(() => {
// 1. acquire the resource
const controller = new AbortController();
window.addEventListener("resize", handleResize);
// 2. return a function that releases it
return () => {
controller.abort();
window.removeEventListener("resize", handleResize);
};
}, [/* dependencies */]);Code Examples
Without the returned cleanup function, every mount of WindowWidth adds another 'resize' listener that is never removed. Navigate to this screen and away from it ten times in a single-page app and you have ten stale listeners all calling setState on ten unmounted component instances.
return () => window.removeEventListener("resize", handleResize);
setInterval keeps firing on its own schedule regardless of whether the component is still mounted. clearInterval(id) in the cleanup function is the only way to stop it; forgetting it means the timer callback keeps running forever and keeps the component's closure — and everything it references — alive.
Common mistake: Calling clearInterval(id) with an id captured from a previous render (e.g. by reading a stale ref) clears the wrong timer and leaks the new one.
Most subscription-based libraries (Redux stores, RxJS observables, custom pub/sub) return an unsubscribe function from their subscribe call specifically so it can be handed straight back to useEffect's cleanup slot — no wrapper arrow function needed.
Closing the socket in cleanup both frees the network connection and stops the 'message' listener from firing after unmount. Because roomId is a dependency, switching rooms correctly closes the old room's socket before opening a new one — without the dependency, the effect would never re-run and the user would stay connected to the first room forever.
createObjectURL pins the file's data in memory under a blob: URL. That memory is not released when the component unmounts, when the <img> tag disappears, or even when the File object itself is garbage collected — only an explicit revokeObjectURL call frees it. Uploading and previewing a hundred images without revoking their URLs can pin hundreds of megabytes for the rest of the page's life.
If the component unmounts (or userId changes) before the fetch resolves, controller.abort() cancels the underlying network request and rejects the promise with an AbortError, which the catch block deliberately ignores. Without this, the .then callback still fires after unmount and calls setUser on a component that no longer exists.
function WindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
function handleResize() {
setWidth(window.innerWidth);
}
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
return <span>{width}px</span>;
}function Countdown({ seconds }: { seconds: number }) {
const [remaining, setRemaining] = useState(seconds);
useEffect(() => {
const id = setInterval(() => {
setRemaining((prev) => Math.max(0, prev - 1));
}, 1000);
return () => clearInterval(id);
}, []);
return <span>{remaining}s</span>;
}function LiveScore({ store }: { store: ScoreStore }) {
const [score, setScore] = useState(store.getScore());
useEffect(() => {
const unsubscribe = store.subscribe(() => setScore(store.getScore()));
return unsubscribe;
}, [store]);
return <span>{score}</span>;
}function ChatRoom({ roomId }: { roomId: string }) {
const [messages, setMessages] = useState<string[]>([]);
useEffect(() => {
const socket = new WebSocket(`wss://chat.example.com/rooms/${roomId}`);
socket.addEventListener("message", (event) => {
setMessages((prev) => [...prev, event.data]);
});
return () => {
socket.close();
};
}, [roomId]);
return <ul>{messages.map((m, i) => <li key={i}>{m}</li>)}</ul>;
}function ImagePreview({ file }: { file: File }) {
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
const objectUrl = URL.createObjectURL(file);
setUrl(objectUrl);
return () => URL.revokeObjectURL(objectUrl);
}, [file]);
return url ? <img src={url} alt="preview" /> : null;
}function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then((res) => res.json())
.then((data) => setUser(data))
.catch((err) => {
if (err.name !== "AbortError") throw err;
});
return () => controller.abort();
}, [userId]);
return user ? <ProfileCard user={user} /> : <Spinner />;
}Important Rules
Every addEventListener, setInterval/setTimeout, .subscribe(), new WebSocket(), and URL.createObjectURL() must have a matching release call, and that release call belongs in the useEffect cleanup function that runs in the same scope as the acquisition.
Cleanup functions must close over the exact same reference they release — capture the timer id, the controller, the socket, or the URL in a local variable inside the effect body, not by re-deriving it from props or state at cleanup time.
An in-memory cache without an eviction policy is a leak by design; give every application-level cache a maximum size, a TTL, or an LRU eviction strategy.
Never store a live DOM node reference outside of a ref that React itself manages; if you must keep a node reference across renders, null it out in cleanup so the detached subtree can be garbage collected.
Diagnosing a leak in production
Chrome DevTools' Memory tab is the standard tool for confirming a suspected leak: take a heap snapshot, interact with the app in the pattern you suspect leaks (e.g. open and close a modal ten times), force a garbage collection, then take a second snapshot and compare retained size. If detached DOM nodes or a component class show up growing across snapshots that should have returned to baseline, you have a confirmed leak, and the 'retainers' tree in that snapshot points at the exact closure or listener still holding the reference. React's own StrictMode double-invokes effects in development specifically to surface a huge class of these bugs early — a component that leaks under StrictMode's double-mount will also leak in production.
useEffect(() => { window.addEventListener('scroll', onScroll); }, []);
useEffect(() => { window.addEventListener('scroll', onScroll); return () => window.removeEventListener('scroll', onScroll); }, []);
Omitting the return statement means the listener is added on every mount and never removed — the most common leak in React codebases.
useEffect(() => { const id = setInterval(tick, 1000); }, []);
useEffect(() => { const id = setInterval(tick, 1000); return () => clearInterval(id); }, []);
An uncleared interval keeps firing after unmount, calling setState on a component that no longer exists and leaking every value its closure captured.
const url = URL.createObjectURL(file); // used once, never revoked useEffect(() => { const url = URL.createObjectURL(file); setUrl(url); return () => URL.revokeObjectURL(url); }, [file]);
Object URLs are not tied to component unmount automatically; the blob's memory stays pinned until revokeObjectURL is called explicitly.
What is the primary reason an uncleared setInterval causes a memory leak in React? A. setInterval allocates DOM nodes that React cannot track B. The interval keeps running after unmount, keeping its closure (and any component state it references) alive and potentially calling setState on an unmounted component C. setInterval callbacks always throw an error that leaks memory D. React automatically doubles every timer in development mode
B. The interval keeps running after unmount, keeping its closure (and any component state it references) alive and potentially calling setState on an unmounted component — An interval that is never cleared keeps firing on its own schedule forever, which keeps everything its callback closure captured alive in memory and can attempt state updates on components that no longer exist.
Which statement about URL.createObjectURL is correct? A. The object URL is automatically revoked when the component that created it unmounts B. The object URL's underlying Blob data stays in memory until URL.revokeObjectURL is called explicitly C. createObjectURL only works inside useEffect D. Object URLs are garbage collected as soon as the File object goes out of scope
B. The object URL's underlying Blob data stays in memory until URL.revokeObjectURL is called explicitly — Object URLs are not tied to React's lifecycle or to garbage collection of the source File/Blob; only an explicit revokeObjectURL call releases the pinned memory.
In an effect that opens a WebSocket, what should the cleanup function do? A. Nothing — WebSockets close themselves when the component unmounts B. Call socket.close() to close the connection and stop further message events from firing C. Reassign the socket variable to null D. Call socket.reconnect() to prepare for the next mount
B. Call socket.close() to close the connection and stop further message events from firing — socket.close() releases the network connection and its listener graph; without it the socket stays open and keeps dispatching 'message' events to a component that no longer exists.
Write a useGeolocationWatch custom hook that calls navigator.geolocation.watchPosition when mounted, stores the latest position in state, and correctly cleans up so that no geolocation callback fires after the hook's owning component unmounts.
function useGeolocationWatch() { const [position, setPosition] = useState<GeolocationPosition | null>(null); useEffect(() => { const id = navigator.geolocation.watchPosition((pos) => setPosition(pos)); return () => navigator.geolocation.clearWatch(id); }, []); return position; } watchPosition returns a numeric watch id; the matching cleanup call is navigator.geolocation.clearWatch(id).