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.

WebSocket retention graph on React unmount A component holds references to a socket, event listeners, an AbortController, and a server subscription; each edge must be cut on cleanup for the heap to reclaim the graph. React component useEffect scope WebSocket close(1000) Event listeners remove + null AbortController abort() Server sub unsubscribe cleanup() cuts all 4 edges = GC reclaims

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 calling ws.close() on a socket that is already CLOSING — you will see spurious errors or a leaked second socket. Guard close calls with a readyState check and the double-invoke becomes a no-op.
  • Closing during CONNECTING. A socket that is unmounted before open fires is not collectable until the handshake resolves. Calling close() on a CONNECTING socket is a silent no-op in some engines; attach a one-shot open listener that closes it instead.
  • Stale closures over state. A message handler that reads state directly 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 unsubscribe frame before close() 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.

  1. 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 WebSocket and Detached:

    Constructor          # Delta   Size Delta
    WebSocket +0 0 B ✅ all reclaimed
    Detached HTMLElement +0 0 B ✅

    A non-zero WebSocket delta means a teardown edge is uncut. Click the retained object and read the Retainers pane to find which reference still points at it.

  2. 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.

  3. 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
  4. Server subscriber count. On the backend, confirm pubsub_channel_subscribers returns 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 #

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.

Back to Frontend WebSocket State Hooks & UI Patterns