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.

The retention chain a snapshot will show you The retention chain runs from the window through the live WebSocket to its listener set, to the handler closure, and finally to the unmounted component the closure captured. The retention chain a snapshot will show you GC root — window always reachable root WebSocket instance alive because the connection is open listeners Set internal to the socket, holds every handler message handler closure captures props, state setters, context Unmounted component + its subtree retained, still receiving updates the leak Removing the listener breaks the chain at the third layer, and everything below it becomes collectable
Read a retainer path bottom-up: the leaked object is at the bottom, and the fix is always to cut the highest link you own.

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.

  1. 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.
  2. In DevTools → Memory, take Snapshot 1.
  3. Navigate into the real-time view and back out, five times.
  4. Force garbage collection (the bin icon), then take Snapshot 2.
  5. Repeat the navigation five more times, force GC, take Snapshot 3.
  6. 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.

Reading the snapshot: symptom to retainer A lookup table mapping what the heap snapshot comparison shows to the retaining structure responsible and the corresponding fix. Reading the snapshot: symptom to retainer What the delta shows Retainer to look for Fix Detached HTMLDivElement component subtree message handler closure remove listener Closure (x10) one per navigation socket listeners Set AbortController Timeout objects pending reconnects timer queue clearTimeout WebSocket (x10) sockets never closed module-level array or Map close + delete Array growing steadily unbounded buffer message history in state cap the buffer A delta that matches your navigation count exactly is the strongest signal you will get — trust it over intuition
Five patterns cover nearly every real WebSocket leak, and each has a one-line fix once the snapshot names it.

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.

Heap growth across navigation cycles With a leaked listener the retained heap grows roughly linearly with the number of navigation cycles, while the fixed version returns to baseline after each forced collection. Heap growth across navigation cycles retained after a forced GC, one dashboard route Leaking MB Fixed MB 0 MB 25 MB 50 MB 75 MB 100 MB 1 cycle 21 MB 5 cycles 43 MB 10 cycles 88 MB 20 cycles
Linear growth after a forced GC is the signature: caches plateau, leaks do not.

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.

Back to Memory Leak Prevention