Finding WebSocket Listener Leaks in DevTools #
The app gets slower the longer it stays open. After an hour of navigating between routes, a tab that started at 90 MB is at 600 MB, scrolling stutters, and React DevTools shows components you unmounted twenty minutes ago still receiving state updates. You suspect the WebSocket layer, but “suspect” is not a diagnosis — and rewriting cleanup code on a hunch usually moves the leak rather than removing it.
Chrome’s memory tools can name the exact retaining reference in about five minutes. This is the procedure.
Root cause #
A WebSocket leak is almost always a listener leak, and listeners leak because the socket outlives the thing that registered them.
When a component registers socket.addEventListener('message', handler), the socket holds a strong reference to handler. That closure captures the component’s scope: props, state setters, refs, sometimes an entire context value. As long as the socket is alive and the listener is registered, none of that can be collected — so a single forgotten removeEventListener retains a whole component subtree, and every remount adds another.
The reason it is hard to spot by reading code is that the reference chain runs backwards from how you think about ownership. You think the component owns the socket; in memory terms the socket owns the component. That is also why the leak grows with navigation rather than with time: each visit adds a listener, and nothing ever removes one.
Three retainers account for nearly all real cases: a listener never removed, a setTimeout for a reconnect never cleared (the timer holds its callback, which holds the scope), and a module-level Map of sessions or subscriptions that outlives every component that wrote to it.
Resolution #
The diagnosis is a three-snapshot comparison. It is reliable because it isolates objects created and retained across a full mount/unmount cycle, which is exactly the definition of this leak.
- Open the page, navigate to the real-time view, then away. This warms caches so the first snapshot is not full of one-time allocations.
- In DevTools → Memory, take Snapshot 1.
- Navigate into the real-time view and back out, five times.
- Force garbage collection (the bin icon), then take Snapshot 2.
- Repeat the navigation five more times, force GC, take Snapshot 3.
- Select Snapshot 3, switch the dropdown to Comparison against Snapshot 1, and sort by Delta.
Anything with a delta of ten (or a multiple of your navigation count) is the leak. Click it, and read the Retainers pane bottom-up — that is the chain in the diagram above, and the highest entry you recognise as your own code is where the fix goes.
Two shortcuts make this faster. In the Summary view, type Detached into the class filter to list detached DOM nodes — a large detached tree is a component that was unmounted but not released. And in the Allocation instrumentation on timeline mode, allocations that survive a GC are shown in blue, so you can watch a leak accumulate live rather than diffing snapshots.
Once you know the retainer, the fix is mechanical:
useEffect(() => {
const socket = new WebSocket(url);
const controller = new AbortController();
// AbortController removes every listener registered with this signal in one
// call — no way to forget one, and no stale handler reference to keep around.
socket.addEventListener('message', onMessage, { signal: controller.signal });
socket.addEventListener('close', onClose, { signal: controller.signal });
socket.addEventListener('error', onError, { signal: controller.signal });
let retryTimer: ReturnType<typeof setTimeout> | undefined;
return () => {
controller.abort(); // 1. detach every listener at once
clearTimeout(retryTimer); // 2. a pending timer retains its closure too
// 3. close only if the socket is actually ours to close; a shared connection
// is closed by its own reference count, not by a consumer unmounting.
if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) {
socket.close(1000, 'unmount');
}
};
}, [url]);
The AbortController pattern is worth adopting even where you currently remove listeners correctly, because it removes the class of bug: there is no per-listener bookkeeping to get wrong when a fourth listener is added later.
Verification #
Prove the fix with the same tool that found the bug, then guard it in CI.
# Automated leak check: navigate repeatedly and read the heap between runs
node --expose-gc scripts/leak-probe.mjs --route /dashboard --cycles 20
// Inside the probe, using Playwright's CDP session
const cdp = await page.context().newCDPSession(page);
await cdp.send('HeapProfiler.collectGarbage');
const before = (await cdp.send('Runtime.getHeapUsage')).usedSize;
for (let i = 0; i < 20; i++) { await page.goto('/dashboard'); await page.goto('/settings'); }
await cdp.send('HeapProfiler.collectGarbage');
const after = (await cdp.send('Runtime.getHeapUsage')).usedSize;
// A fixed app returns near baseline; a leaking one grows roughly linearly.
expect(after - before).toBeLessThan(5 * 1024 * 1024);
The threshold is deliberately generous — caches and JIT state grow legitimately — but a leak of the kind described here grows with the cycle count, so twenty cycles makes a real leak obvious and a false positive unlikely. Run it on a schedule rather than on every pull request; it takes a minute and it catches the regression that reintroduces the listener.
Operational checklist #
FAQ #
Why does the heap keep growing even after I removed the listener? #
Either there is a second retainer, or the growth is not a leak. Take the comparison snapshot again and check whether the delta still matches your cycle count: a leak scales with cycles, while caches, JIT code and image decoding plateau. If the delta persists, the retainer pane will name the new holder — very often a module-level Map or an unbounded message-history array that the listener fix did not touch.
Do I need to remove listeners if I close the socket? #
Strictly, a closed socket becomes collectable and takes its listeners with it — but only if nothing else references the socket. In practice something usually does: a ref, a pending timer, an entry in a connections map. Removing listeners explicitly is cheap and makes the cleanup correct regardless of what else holds the socket, which is why useWebSocket cleanup and teardown patterns treats it as mandatory.
Does React StrictMode cause these leaks? #
No — it exposes them. StrictMode deliberately runs effects mount → cleanup → mount so that incomplete teardown produces a visible duplicate rather than a silent one. If your socket count doubles in development and not in production, the double-invoke is telling you the cleanup is incomplete, and production is leaking too, just half as fast.
Can I detect leaks without DevTools? #
Partially. performance.memory.usedJSHeapSize gives a coarse number you can log in a synthetic test, and the CDP-based probe above automates it. What you cannot get outside DevTools is the retainer path, which is the part that tells you which reference to cut — so use automation to detect regressions and the snapshot UI to diagnose them.
Does this apply to Server-Sent Events and other transports too? #
Yes, identically. Any long-lived subscription object holding a callback creates the same retention chain — EventSource, an IntersectionObserver, a ResizeObserver, a store subscription. The transport differs; the shape of the bug and the snapshot procedure do not.
Related #
- Memory Leak Prevention — the patterns that stop these leaks being written in the first place.
- Preventing Memory Leaks in React useEffect WebSockets — the effect-level rules this page diagnoses violations of.
- useWebSocket Cleanup and Teardown Patterns — the complete teardown contract.
- Sharing One WebSocket Across React Components — why a consumer must not close a connection it does not own.
- Fixing Slow Consumer Memory Growth — the server-side version of the same investigation.
Back to Memory Leak Prevention