Memory Leak Prevention in Real-Time React Apps #
A dashboard runs fine in QA, then falls over after eight hours on a trading floor. The tab is holding 1.8 GB, frame rate has collapsed, and a heap snapshot shows 340 detached WebSocket objects — one per route change the user made all day. Nothing crashed; the connection layer just never let go. This is the signature failure of WebSocket integrations in declarative UI frameworks: every component that opens a socket, registers a listener, or subscribes to a backend channel creates a retention path, and if even one teardown step is skipped the V8 garbage collector can never reclaim the graph.
This page covers how to make WebSocket teardown deterministic in React — closing the socket, detaching every handler, cancelling in-flight work, and propagating the disconnect to the server — so that an unmount returns the heap to its baseline every time. It is about lifecycle and reference hygiene, not data shape or transport choice.
Prerequisites #
This builds on connection patterns established elsewhere. You should already have:
- A working socket layer. The connection and reconnection logic here assumes the conventions from React WebSocket Custom Hooks — a single hook owns the socket, not scattered
new WebSocket()calls in components. - Server-side liveness. Heartbeats and idle timeouts must be configured per Connection Lifecycle & Heartbeats, or zombie connections will leak on the backend regardless of how clean the client is.
- React 18+ with StrictMode enabled in development, so double-invoked effects surface non-idempotent cleanup early.
The parent area, Frontend WebSocket State Hooks & UI Patterns, frames how these teardown rules interact with rendering and state.
The retention graph #
A leak is never “the socket leaked” — it is a chain of references that keeps the socket (and everything it closes over) reachable from a GC root. The diagram below traces the four edges you must cut on unmount. Cut all four and the whole subgraph becomes collectable; leave one and the rest survives with it.
Core implementation #
The pattern that closes all four edges is a single effect that owns the socket, captures a controller, and returns a cleanup function. The cleanup is idempotent — safe to run twice under StrictMode — and gates every state update so a message arriving mid-teardown cannot resurrect a dead component.
import { useEffect, useRef, useState } from "react";
const NORMAL_CLOSURE = 1000; // RFC 6455 graceful close code
const CLIENT_TEARDOWN = "client unmount";
interface ChannelState {
[id: string]: unknown;
}
export function useRealtimeChannel(url: string, channel: string) {
const [state, setState] = useState<ChannelState>({});
// One controller per effect run; abort() is the single kill switch.
const controllerRef = useRef<AbortController | null>(null);
useEffect(() => {
const controller = new AbortController();
controllerRef.current = controller;
const ws = new WebSocket(url);
// Passing the signal auto-detaches the listener on abort() — no manual
// removeEventListener bookkeeping, and it survives StrictMode double-mount.
ws.addEventListener("open", () => {
ws.send(JSON.stringify({ action: "subscribe", channel }));
}, { signal: controller.signal });
ws.addEventListener("message", (e) => {
if (controller.signal.aborted) return; // gate: never set state post-teardown
try {
const { id, payload } = JSON.parse(e.data);
setState((prev) => ({ ...prev, [id]: payload }));
} catch {
// malformed frame: drop, do not throw inside the listener
}
}, { signal: controller.signal });
return () => {
controller.abort(); // detaches all signal-bound listeners
// Tell the server to drop the subscription before we close the socket,
// so the send is still flushable on an OPEN connection.
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ action: "unsubscribe", channel }));
ws.close(NORMAL_CLOSURE, CLIENT_TEARDOWN);
} else if (ws.readyState === WebSocket.CONNECTING) {
// Socket never opened: close once it does, or the handshake leaks it.
ws.addEventListener("open", () => ws.close(NORMAL_CLOSURE, CLIENT_TEARDOWN));
}
controllerRef.current = null; // drop the last strong reference
};
}, [url, channel]); // re-running tears down cleanly first
return state;
}
The AbortController signal is the load-bearing detail. Binding listeners with { signal } means one abort() call removes every listener attached with that signal — there is no per-handler removeEventListener to forget. The aborted check inside message is what stops the classic “set state on an unmounted component” path that pins the React fiber in the heap. The CONNECTING branch handles the socket that is still mid-handshake at unmount; closing it on open is the only way to avoid a detached socket that finishes connecting after the component is gone.
Configuration reference #
| Parameter | Type | Default | Production value | Notes |
|---|---|---|---|---|
closeCode |
number | 1005 (none) |
1000 |
Always pass 1000 so the server logs a clean close, not an abnormal 1006. |
signal on listeners |
AbortSignal |
none | controller.signal |
Auto-detaches listeners on abort(); eliminates manual removeEventListener. |
terminationGracePeriodSeconds |
number | 30 |
40 |
Server-side: lets draining flush unsubscribe frames before SIGKILL. |
MAX_BUFFERED_AMOUNT |
number | unbounded | 1_048_576 |
Stop sending when ws.bufferedAmount exceeds 1 MB; backpressure prevents unbounded queue growth. |
heartbeatTimeoutMs |
number | none | 35000 |
Slightly above server ping interval; closes half-open sockets that would otherwise leak. |
| StrictMode | boolean | true (dev) |
true |
Keep on — it double-invokes effects and exposes non-idempotent cleanup. |
Edge cases & gotchas #
- StrictMode double-mount. React 18 mounts, unmounts, then remounts every component once in development. If
cleanup()is not idempotent — for example callingws.close()on a socket that is alreadyCLOSING— you will see spurious errors or a leaked second socket. Guard close calls with areadyStatecheck and the double-invoke becomes a no-op. - Closing during
CONNECTING. A socket that is unmounted beforeopenfires is not collectable until the handshake resolves. Callingclose()on aCONNECTINGsocket is a silent no-op in some engines; attach a one-shotopenlistener that closes it instead. - Stale closures over
state. Amessagehandler that readsstatedirectly closes over the first render’s value and keeps that snapshot alive. Always use the functional updater (setState(prev => ...)) so the handler captures nothing from render scope. - Server-side zombies. A clean client close still leaks on the server if the backend never unsubscribes the channel. The explicit
unsubscribeframe beforeclose()is what prevents the pub/sub subscriber list from growing without bound across reconnects.
Verification #
Prove the heap returns to baseline rather than trusting the code by inspection.
-
Heap snapshot diff. In Chrome DevTools → Memory, take a snapshot, mount and unmount the component 20 times, force GC (the trash-can icon), and take a second snapshot. Diff them and filter the Summary by
WebSocketandDetached:Constructor # Delta Size Delta
WebSocket +0 0 B ✅ all reclaimed
Detached HTMLElement +0 0 B ✅A non-zero
WebSocketdelta means a teardown edge is uncut. Click the retained object and read the Retainers pane to find which reference still points at it. -
Listener count. With the component mounted, run in the console:
// getEventListeners is a DevTools console helper, not page JS
getEventListeners(document.querySelector("#root")).length;Mount/unmount in a loop; the count must be flat, not monotonically rising.
-
Live socket count. A quick assertion that no orphan sockets survive:
# in the DevTools console, after unmounting everything
performance.getEntriesByType("resource")
.filter(r => r.name.startsWith("ws")).length -
Server subscriber count. On the backend, confirm
pubsub_channel_subscribersreturns to baseline after the client tab closes — a flat-then-rising line across reconnects is the server-side half of the leak.
Guides in this area #
- Preventing Memory Leaks in React useEffect WebSockets — a step-by-step walkthrough of the
useEffectcleanup contract, including the StrictMode double-mount trap and heap-snapshot diffing in DevTools.
Finding the leak rather than guessing at it #
Rewriting cleanup code on a hunch usually moves a leak rather than removing it. Chrome’s memory tools name the retaining reference in about five minutes, and the procedure is mechanical enough to hand to anyone.
Take three heap snapshots: one after warming the page, one after five mount-and-unmount cycles through the real-time route, and one after five more. Force garbage collection before each — otherwise you will chase objects that were about to be collected anyway. Then compare the third snapshot against the first and sort by delta. Anything whose count grew by ten, or by a multiple of your cycle count, is the leak; anything that grew and then plateaued is a cache.
Click the offending entry and read the Retainers pane from the bottom up. That is the reference chain, and it runs in the direction people find counter-intuitive: you think the component owns the socket, and in memory terms the socket owns the component. The highest link in that chain that belongs to your own code is where the fix goes.
Two shortcuts make this faster. Filtering the Summary view by Detached lists DOM nodes that are no longer in the document but are still referenced — a large detached tree is a component that unmounted without releasing. And the allocation timeline shows surviving allocations in blue as they happen, which lets you watch a leak accumulate live instead of diffing snapshots.
The fixes themselves are short once the retainer is named. A listener never removed is fixed by registering everything with an AbortController signal so one abort() detaches all of them; a pending timer is fixed by clearing it before closing the socket; a module-level map that outlives every component is fixed by deleting on close rather than on an explicit action a dropped client never sends.
Guard the fix with automation, because the regression that reintroduces a listener is one careless refactor away. A scheduled probe that navigates twenty cycles, forces collection and asserts the retained heap stayed near baseline takes a minute to run and catches exactly this.
FAQ #
Why does my socket leak even though I call ws.close() in cleanup? #
close() only releases the socket once every listener and timer that references it is also gone. A lingering setInterval heartbeat, an onmessage assigned outside the effect, or a closure capturing state will keep the object reachable. Cut all four edges from the retention graph — socket, listeners, controller, and server subscription — not just the socket.
Do I need both an AbortController and an isMounted ref? #
No. An AbortController does everything the isMounted ref did and more: controller.signal.aborted gates state updates, and binding listeners with { signal } auto-detaches them. The ref-only approach is a holdover from before addEventListener accepted a signal; one controller per effect is cleaner and leaves nothing to forget.
Does StrictMode cause real memory leaks or just warnings? #
It surfaces real ones. The double mount/unmount runs your cleanup an extra time; if cleanup is not idempotent it either throws or leaks a second socket. Treat any StrictMode error as a production bug — the same path runs whenever an effect re-runs on a dependency change.
How is this different from the server-side leak? #
This page handles the browser heap. The server keeps its own per-connection state — channel subscriptions, heartbeat timers, buffered frames. A clean client teardown that never sends unsubscribe still grows the server’s subscriber list. Pair this with server-side idle timeouts and disconnect handling from Connection Lifecycle & Heartbeats.
Does React StrictMode cause leaks or expose them? #
Expose them. StrictMode deliberately runs effects as mount, cleanup, mount so that incomplete teardown produces a visible duplicate instead of a silent one. If your socket count doubles in development and grows steadily in production, both are the same bug — StrictMode is showing it to you at twice the rate rather than creating it.
Why does the heap keep growing after I fixed the listener? #
Either there is a second retainer or the growth is not a leak. Re-run the comparison and check whether the delta still tracks your cycle count: leaks scale with cycles, while caches, JIT code and decoded images 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 the listener fix never touched.
Do these techniques apply outside React? #
Entirely. Any long-lived subscription holding a callback creates the same chain — an EventSource, an IntersectionObserver, a store subscription, a Vue watcher. The framework changes which API registers the callback; it does not change the retention shape, the snapshot procedure, or the fix.
What is the cheapest way to prevent all of this? #
Register every listener with an AbortController signal and store every timer handle in a ref. Cleanup then becomes two lines — controller.abort() and clearTimeout — that cannot be partially applied and cannot be forgotten when a fourth listener is added later. Most WebSocket leaks are not subtle bugs but missing lines in a teardown path that grew one listener at a time, and removing the per-listener bookkeeping removes the whole class.
Should message history live in component state? #
Only if it is bounded. An array that appends every incoming message grows without limit for as long as the tab is open, and because it is legitimately referenced by live state, no heap snapshot will call it a leak — it simply looks like an application that needs a gigabyte. Cap it at whatever the interface can actually display, and keep anything longer in a store the user explicitly loads.
Does an unclosed socket keep the page alive after navigation? #
In a single-page application, yes — a socket you never closed keeps its listeners, its buffers and everything those closures captured alive for as long as the tab lives, across every route the user visits afterwards. A full page navigation tears everything down regardless, which is why the leak is invisible in traditional server-rendered apps and accumulates steadily in SPAs. The route change is the boundary you have to clean up at, because nothing else will.
Do WeakMap and WeakRef solve this? #
Partly, and in one specific place. Keying per-connection bookkeeping on a WeakMap means your diagnostic state cannot itself become the leak — when the socket is collected, its entry goes with it. What they cannot fix is the listener, because the socket holds a strong reference to every handler registered on it and no weak reference changes that. Use WeakMap for state you attach to a socket, and explicit removal for anything the socket attaches to you.
Related #
- React WebSocket Custom Hooks — the hook conventions this teardown pattern plugs into.
- State Sync & Optimistic Updates — keeping local state coherent without unbounded arrays that leak.
- Vue 3 Composables for Real-Time — the same teardown discipline expressed with
onUnmountedwatchers. - Connection Lifecycle & Heartbeats — server-side liveness that prevents zombie connections.
- Preventing Memory Leaks in React useEffect WebSockets — the focused walkthrough beneath this page.