The symptom is always the same shape: a page that feels fine right after load and gets progressively janky the longer someone leaves it open, a dashboard, a chat view, anything with a poll or a live subscription. Scrolling gets stuttery, tab switches lag, and eventually the tab itself slows the whole browser down. That’s a memory leak, and it’s one of the few classes of bug where guessing at the code is slower than just learning the one DevTools workflow that finds it directly.
Step one: confirm it’s actually a leak
Memory usage going up isn’t automatically a leak, apps legitimately hold more memory as a user does more. A leak is memory that keeps growing and never comes back down even when it logically should. Chrome DevTools’ Memory tab, heap snapshot mode, is the tool: take a snapshot, interact with the page the way a user would for a minute (switch tabs, open and close a few things), take a second snapshot, and compare. The comparison view shows a “# Delta” column, objects that exist in the second snapshot but weren’t cleaned up from the first. If that number keeps climbing every time you repeat the cycle, it’s a real leak, not normal usage.
The four causes that account for almost every React leak
1. Event listeners added without cleanup
A useEffect that adds a listener to window or document and doesn’t return a cleanup function leaks one listener every time the component mounts. On a component that mounts and unmounts repeatedly, a modal, a tab panel, that adds up fast, and each listener closure keeps whatever it references alive.
// Leaks: a new listener every mount, none of them ever removed
useEffect(() => {
window.addEventListener("resize", handleResize);
}, []);
// Fixed: the cleanup function runs on unmount
useEffect(() => {
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);2. Intervals and timeouts that outlive the component
The same pattern, more often missed because it doesn’t error, it just keeps a timer firing (and whatever it references alive) long after the component that started it is gone.
useEffect(() => {
const id = setInterval(() => pollStatus(), 5000);
return () => clearInterval(id);
}, []);3. Subscriptions that never unsubscribe
A WebSocket connection, an RxJS observable, a Firestore listener, anything with an explicit subscribe call needs an equally explicit unsubscribe in cleanup. This one is easy to miss because the subscription object often gets created inside a callback rather than the top-level effect, and it’s easy to lose track of the reference you need to tear it down with.
useEffect(() => {
const unsubscribe = subscribeToChannel(channelId, (msg) => {
setMessages((prev) => [...prev, msg]);
});
return () => unsubscribe();
}, [channelId]);4. A closure holding a stale reference somewhere long-lived
This is the subtle one. A callback gets passed into something that outlives the component, a module-level event emitter, a ref stored outside React’s tree, a singleton cache, and that callback closes over props or state from the render it was created in. Every re-render can create a new closure that gets added without the old one ever being removed, and each one keeps its entire closure scope, including large objects, alive in memory.
The detached DOM node trap
In a heap snapshot, search for “Detached”. A detached node is an element that’s been removed from the visible DOM tree, React unmounted it, but something in JavaScript still holds a reference to it, an event listener bound to it directly instead of via delegation, a ref stored somewhere that outlives the component, a closure from cause four above. As long as that reference exists, the browser can’t garbage collect the node or anything it was holding onto. A growing count of detached nodes between two snapshots is one of the clearest possible signals you’re looking at a real leak, and the retainer tree in DevTools (click into one of the detached nodes) shows you exactly what’s still holding the reference.
Verifying the fix actually worked
Don’t trust that the code looks right now, prove it with the same tool you used to find the problem. Take a snapshot, interact with the page through several cycles of whatever triggered the leak (open/close the modal a dozen times, let the poll run for a few minutes), force garbage collection with the trash-can icon in the Memory tab, then take another snapshot. If the retained count for the object type you fixed stays flat across repeated cycles instead of climbing, the fix held.
The actual takeaway
Almost every React memory leak traces back to something started in an effect without a matching cleanup, a listener, a timer, a subscription, or a closure with a longer lifetime than the component that created it. The fix is rarely clever once you’ve found it. The time is almost entirely in the finding, and the Memory tab’s snapshot comparison is the fastest way there, faster than reading through effects guessing which one is the culprit.