A story about a tab that got slower the longer you left it open, the heap snapshots that finally explained why, and the boring patterns that stopped it.
Nobody noticed it for months. The app loaded fast, the first interaction felt snappy, the demos went fine. The bug report that finally surfaced it was almost apologetic: "The dashboard gets laggy if I leave it open all day. Refreshing fixes it."
That last sentence is the tell. Refreshing fixes it. Whenever a refresh makes a performance problem disappear, you're not looking at a slow algorithm or a heavy render. You're looking at something that accumulates — something that grows in memory the longer the page lives. For a typical content site nobody would ever catch it, because nobody keeps a tab open for eight hours. But our users are operations people who open the dashboard at 9am and close their laptop at 6pm. The tab was the session.
What follows is how I found it, what was actually causing it, and the handful of unglamorous habits that keep it from coming back.
First: confirm it's actually a leak
Before chasing anything, you have to prove the memory is growing and not being reclaimed. "The app feels slow over time" has a dozen causes, and most of them aren't leaks. So the first thing I did was open Chrome DevTools, go to the Performance panel, check "Memory," and record while I used the app the way a real user would for a few minutes — clicking around, navigating between views, coming back.
The shape of the graph is everything. A healthy app has a sawtooth: memory climbs as you work, then garbage collection drops it back down. A leak has a staircase — it climbs, GC shaves a little off, but the floor keeps rising. Every cycle ends higher than the last.
Ours was a staircase. The baseline crept up about 8MB every time I navigated away from the dashboard view and back. Do that fifty times over a workday and you've got half a gigabyte of garbage the browser can't reclaim.
That's the moment to stop guessing and take heap snapshots.
The tool that actually finds the culprit
The Memory panel in DevTools, with heap snapshots, is the only thing that reliably answers what is being retained. The technique that works is the three-snapshot comparison:
- Load the app, get to a clean state, take a snapshot.
- Perform the suspect action a bunch of times — navigate away and back ten times.
- Return to the same clean state, force GC (the trash-can icon), take a second snapshot.
Then switch the snapshot view to "Comparison" and sort by the delta. If your app returned to the same clean state, the object counts should be roughly flat. Anything with a large positive delta — objects that were created and never freed — is your leak.
In our snapshot, the offender was unmistakable: hundreds of Detached HTMLDivElement nodes and a steadily growing count of one specific component's closures. Detached DOM nodes are the classic signature. They're elements that have been removed from the page but are still referenced by something in JavaScript, so the garbage collector can't touch them.
The "Retainers" panel tells you what that something is. Ours pointed at an event listener.
The root cause nobody removes
Here's the pattern that was sitting in three different components:
function LiveChart({ socketUrl }) {
const [data, setData] = useState([]);
useEffect(() => {
const socket = new WebSocket(socketUrl);
socket.onmessage = (event) => {
setData((prev) => [...prev, JSON.parse(event.data)]);
};
window.addEventListener('resize', handleResize);
// ...and that's it. No cleanup.
}, [socketUrl]);
return <div ref={chartRef}>{/* ... */}</div>;
}
Looks completely reasonable. It works. It even works correctly — the chart updates, the resize handler fires. The problem only exists across the component's lifecycle.
Every time LiveChart unmounts and a new one mounts, we open a new WebSocket and add a new resize listener — but we never close the old socket or remove the old listener. The old socket keeps its onmessage closure alive. That closure captures setData, which keeps the component's entire fiber alive, which keeps its DOM nodes alive even after React detached them from the page.
Multiply that by every navigation and you have the staircase. The DOM nodes were detached but immortal.
The useEffect cleanup function exists for exactly this, and we'd skipped it:
useEffect(() => {
const socket = new WebSocket(socketUrl);
socket.onmessage = (event) => {
setData((prev) => [...prev, JSON.parse(event.data)]);
};
const handleResize = () => { /* ... */ };
window.addEventListener('resize', handleResize);
return () => {
socket.close();
window.removeEventListener('resize', handleResize);
};
}, [socketUrl]);
Three lines. That return function was the entire fix for two of the three leaks.
The subtler leak: subscriptions that outlive the component
The third one was nastier because there was no obvious listener to remove. We had a small global store — a hand-rolled pub/sub — and components subscribed to it on mount:
useEffect(() => {
store.subscribe(handleUpdate);
}, []);
There's no removeEventListener here to forget. But store.subscribe pushes handleUpdate into an internal array inside the store, and that array lives for the entire life of the page. The store is a global singleton — it never unmounts, never gets garbage collected, and it's holding a reference to a callback from a component that was destroyed twenty navigations ago. Through that callback, it holds the component too.
This is the leak people miss, because nothing looks wrong. The subscription API just didn't return an unsubscribe handle, and we never asked for one. The fix was to make subscribe return a teardown function and actually call it:
useEffect(() => {
const unsubscribe = store.subscribe(handleUpdate);
return unsubscribe;
}, []);
The lesson that generalized: anything you register against something that lives longer than your component, you are responsible for unregistering. Sockets, intervals, event listeners, store subscriptions, observers, animation frames. If the thing you attached to outlives you, your cleanup is the only thing standing between you and a leak.
The timers and observers checklist
Once I knew what to look for, I grepped the codebase for the usual suspects. Every one of these is a leak waiting to happen if its teardown is missing:
setInterval without a matching clearInterval. setTimeout in a component that might unmount before it fires. IntersectionObserver, ResizeObserver, and MutationObserver without .disconnect(). requestAnimationFrame loops without cancelAnimationFrame. Any .addEventListener on window, document, or a DOM node outside the component without the matching removeEventListener.
We found four more latent leaks this way that hadn't surfaced yet — a setInterval polling a status endpoint that kept running after navigation, and three observers that never disconnected. None of them were causing visible problems yet. They would have.
The habit that catches it before users do
Performance work without a guardrail is gardening — you weed it once and it grows back the next sprint. So we added two cheap habits.
First, a lint rule. eslint-plugin-react-hooks won't catch a missing cleanup on its own, but we wrote a small custom rule that flags any useEffect that creates a WebSocket, calls addEventListener, setInterval, or subscribe without returning a function. It's not bulletproof, but it turns "we forgot" into a review comment instead of a production incident.
Second — and this is the one that actually pays off — a memory regression check on the heaviest view. Before a release, someone takes the three-snapshot comparison on the dashboard: clean state, navigate away and back ten times, force GC, compare. If the detached-node count isn't roughly flat, the release waits. It takes five minutes and it has caught two regressions since we started.
What I'd tell past-me at the start of this
Don't start by reading code looking for the leak — you'll stare at a hundred reasonable-looking useEffects and find nothing, because every one of them looks fine in isolation. Start with the Performance panel to confirm the staircase, then let heap snapshots tell you what is retained and the Retainers panel tell you who is holding it. The tools point straight at the answer. Reading code is what you do after they've narrowed it to three files.
And the deeper thing: memory leaks are lifecycle problems, not memory problems. Every leak I found traced back to the same gap — something got set up on the way in and nothing tore it down on the way out. React gives you exactly one place to express that symmetry, the useEffect cleanup, and the bug is almost always that the cleanup half is simply missing. Setup and teardown are a pair. Write them together, or you'll debug them apart.
We have a standing rule now: any useEffect that opens, attaches, subscribes, or starts something gets reviewed for the matching close, remove, unsubscribe, or stop — in the same PR. It takes thirty seconds. It's saved us days.
Found this useful? A reaction helps other developers find it. I write about React, frontend architecture, and the unglamorous parts of shipping software at scale.
Top comments (0)