useWebSocket Cleanup and Teardown Patterns #

You unmounted a component that owned a WebSocket, and instead of going quiet your app opened a new socket — sometimes two. The Network tab shows extra 101 Switching Protocols rows after navigation, a reconnect timer keeps firing for a route you already left, and React logs “Can’t perform a state update on an unmounted component.” Every one of these is a teardown-ordering bug. The fix is not more code; it is doing the same few steps in the right order. This guide walks through the exact order a useWebSocket React hook must follow when its useEffect cleanup runs.

Root cause #

A browser WebSocket is an event emitter with four handler slots — onopen, onmessage, onerror, onclose — and a readyState that walks through CONNECTING (0) → OPEN (1) → CLOSING (2) → CLOSED (3). Calling close() does not detach those handlers. It transitions the socket toward CLOSED and, when the close completes, fires onclose exactly once. If your onclose schedules a reconnect, then close() is not teardown — it is a trigger for the very reconnect you are trying to stop. Teardown order is therefore load-bearing: null the handlers first, close second.

There are three more states the cleanup has to survive:

  • Closing a CONNECTING socket. If cleanup runs before the handshake finishes (readyState === 0), calling close() is legal but the socket still has to reach OPEN or abort before it settles. The browser emits a console error if you call close() on a socket that never opened in some engines, and any send() you attempt in that window throws InvalidStateError. Guard on readyState.
  • A pending reconnect timer. Your reconnect logic almost certainly uses setTimeout. If the component unmounts during the backoff window, that timer is still armed. When it fires it opens a socket into a dead component — a leak that survives unmount and outlives the route. Cleanup must clearTimeout the stored handle.
  • React 18 StrictMode double-invoke. In development, StrictMode runs every effect as mount → cleanup → mount to flush out non-idempotent effects. A hook that opens a socket on mount and tears it down on cleanup will, under StrictMode, open-close-open. If teardown is incomplete, the discarded first socket lingers with live handlers, and its onclose reconnect races the second mount. Correct teardown makes the discard total, so the double-invoke is harmless.

The leak below looks reasonable and is wrong on every count:

// LEAKY — do not ship
useEffect(() => {
const ws = new WebSocket(url);
ws.onclose = () => setTimeout(() => connect(), RECONNECT_DELAY_MS); // reconnect on close
ws.onmessage = (e) => setMessages((m) => [...m, e.data]); // state update, no mounted guard
return () => ws.close(); // close() FIRST → fires onclose → schedules a reconnect into a dead tree
}, [url]);

ws.close() runs, onclose fires, the reconnect timer is armed, and the timer is never cancelled because nobody kept its handle. Under StrictMode this happens twice on first mount.

StrictMode mount, cleanup, remount timeline Under StrictMode an effect mounts, cleans up, then mounts again. Correct teardown nulls handlers and cancels the timer so no orphan socket survives the first cleanup. StrictMode dev timeline Mount #1 new WebSocket(url) readyState 0 Cleanup null handlers clear timer, close() Mount #2 fresh socket no orphan Handlers nulled before close() means onclose never fires a reconnect Mount #1 socket is fully discarded — Mount #2 is the only live socket

Resolution #

The hook stores both the socket and the reconnect timer in refs so the cleanup closure always sees the live handles. Teardown nulls handlers first, cancels the timer, then closes only a CONNECTING/OPEN socket. A closedByCleanup flag tells onclose whether to reconnect.

import { useEffect, useRef, useState, useCallback } from 'react';

const RECONNECT_DELAY_MS = 2000;

export function useWebSocket(url: string) {
const wsRef = useRef<WebSocket | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [status, setStatus] = useState<'connecting' | 'open' | 'closed'>('closed');

// teardown is its own function so the effect and a manual disconnect share it
const teardown = useCallback(() => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current); // 1. cancel any armed reconnect FIRST
timerRef.current = null;
}
const ws = wsRef.current;
if (ws) {
ws.onopen = null; // 2. detach handlers BEFORE close()...
ws.onmessage = null;
ws.onerror = null;
ws.onclose = null; // ...so onclose cannot schedule a reconnect
// 3. only close a socket that is still live; CLOSING/CLOSED would throw or no-op
if (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN) {
ws.close(1000, 'component cleanup'); // 1000 = normal closure
}
wsRef.current = null;
}
setStatus('closed');
}, []);

const connect = useCallback(() => {
const ws = new WebSocket(url);
wsRef.current = ws;
setStatus('connecting');

ws.onopen = () => setStatus('open');
ws.onclose = () => {
// reached only on a real network close — cleanup nulled this handler already
setStatus('closed');
timerRef.current = setTimeout(connect, RECONNECT_DELAY_MS); // arm reconnect, keep the handle
};
}, [url]);

useEffect(() => {
connect();
return teardown; // StrictMode: mount → teardown → mount, each side total
}, [connect, teardown]);

return { status };
}

The ordering is the whole point. clearTimeout runs before close() so a reconnect that was already scheduled by a prior onclose cannot fire. Handlers are nulled before close() so this cleanup’s own close never re-enters onclose. The readyState guard keeps you from calling close() on a socket the browser already moved to CLOSING/CLOSED. Because teardown is referentially stable (useCallback with []), StrictMode’s second invoke runs the identical closure against fresh refs.

Operational checklist #

  • Null onopen, onmessage, onerror, and onclose before calling close()
  • Store the reconnect setTimeout handle in a ref and clearTimeout
  • Guard close() behind readyState === CONNECTING || readyState === OPEN; never close CLOSING/CLOSED
  • Run the component under <React.StrictMode>
  • Verify the Network tab shows a single 101 Switching Protocols
  • Make teardown and connect stable with useCallback

FAQ #

Why null the handlers before calling close() instead of after? #

close() is asynchronous and resolves into the onclose handler. If the handler is still attached when you call close(), your reconnect logic fires during teardown. Nulling onclose first makes the close silent. Doing it after close() is a race you will lose intermittently.

Do I still need this if I use addEventListener instead of onclose? #

Yes, with a twist. The addEventListener style does not have a single nullable slot, so you must call removeEventListener with the exact same function reference, or call close() on a socket whose listeners you control via an AbortController signal. The ordering rule is identical: detach listeners, then close.

Is the readyState guard necessary if I only close OPEN sockets? #

The hard case is the CONNECTING socket. If a component mounts and unmounts faster than the handshake completes — common during fast navigation or StrictMode — readyState is 0, never 1. Without the guard you either skip the close (leak) or hit an engine that errors on closing a never-opened socket. Guard on CONNECTING || OPEN.

Does this matter outside StrictMode in production? #

StrictMode only makes the bug reproducible on every mount. The same teardown race fires in production on any unmount that overlaps a pending reconnect or an in-flight handshake — fast route changes, conditional rendering, modal close. Production hides the bug; it does not remove it. Treat StrictMode as your free regression test.

Where do I put the broader memory-leak checks? #

Teardown ordering stops the socket-level leak. For the surrounding hazards — state updates after unmount, dangling subscriptions, and heap growth across navigation — see the memory leak prevention guide, which pairs directly with this teardown pattern.

Back to React WebSocket Custom Hooks.