[ EXECUTIVE TEARDOWN // TL;DR ]
- Model reconnection as four explicit states; ad-hoc booleans end with two sockets open at once.
- Add full jitter to exponential backoff, otherwise every client reconnects in unison after a restart.
- An open socket can still be dead; heartbeat both ends and use terminate rather than close for an unresponsive peer.
- Reconnecting restores the pipe, not the missed data; send a last-seen event id and let the server replay.
Every real-time feature starts the same way: open a socket, handle messages, ship it. Then a laptop lid closes, a server deploys, a train goes into a tunnel, and you discover that reconnection is most of the work.
Reconnect logic written as a handful of booleans — isConnecting, hasReconnected, shouldRetry — always ends up with a state nobody planned for, usually two sockets open at once. It is worth writing as an explicit state machine on the first day.
Four states, and only four
connect() open
IDLE ─────────────▶ CONNECTING ─────▶ OPEN
▲ │ │
│ │ error │ close
│ ▼ ▼
└──────────────── WAITING ◀──────────┘
give up / stop (backoff timer)
- IDLE — nothing open, nothing scheduled.
- CONNECTING — a socket exists but has not opened.
- OPEN — usable.
- WAITING — closed, with a timer scheduled to try again.
The rule that prevents nearly every bug: a transition may only happen from the state that owns it. A close event arriving while in WAITING is ignored, because that close belongs to a socket already abandoned. Without that check, a slow close event schedules a second reconnect and you get two sockets, then four.
Backoff, and why jitter is not optional
Retrying immediately in a loop is how a client turns a brief server hiccup into an outage. Exponential backoff spaces attempts out:
const BASE_MS = 500;
const MAX_MS = 30_000;
function delayFor(attempt: number) {
const exponential = Math.min(MAX_MS, BASE_MS * 2 ** attempt);
// Full jitter: pick anywhere in [0, exponential].
return Math.random() * exponential;
}
The jitter matters more than the exponent. When a server restarts, every connected client is disconnected at the same instant. Without jitter they all back off by the same amount and reconnect in unison, hammering the server the moment it comes back — the thundering herd. Randomising the delay spreads them out, and it costs one line.
Heartbeats, because "open" lies
A socket in the OPEN state is not necessarily alive. If the network drops without a clean close — a dead router, a sleeping phone — the socket stays open until the OS eventually gives up, which can take minutes. The user sees a UI that looks connected and never updates.
Both ends need a heartbeat. Server side, ws gives you the primitives:
const HEARTBEAT_MS = 30_000;
wss.on("connection", (socket) => {
socket.isAlive = true;
socket.on("pong", () => { socket.isAlive = true; });
});
setInterval(() => {
for (const socket of wss.clients) {
if (!socket.isAlive) {
socket.terminate(); // not close(): the peer is already gone
continue;
}
socket.isAlive = false;
socket.ping();
}
}, HEARTBEAT_MS);
terminate() rather than close() is deliberate. close() starts a closing handshake with a peer that is not answering, so the socket lingers.
Browsers cannot send pings from JavaScript, so the client side is an application-level timer: if no message of any kind has arrived in HEARTBEAT_MS * 2, assume the connection is dead and close it yourself, which drops you into WAITING and triggers the normal reconnect path.
The React side
The socket belongs in a ref, not in state. Its status belongs in state, because the UI genuinely needs to re-render when it changes.
type Status = "idle" | "connecting" | "open" | "waiting";
function useSocket(url: string) {
const [status, setStatus] = useState<Status>("idle");
const socketRef = useRef<WebSocket | null>(null);
const attemptRef = useRef(0);
useEffect(() => {
let disposed = false;
let timer: number | undefined;
const connect = () => {
setStatus("connecting");
const socket = new WebSocket(url);
socketRef.current = socket;
socket.onopen = () => {
attemptRef.current = 0; // reset backoff only on a real open
setStatus("open");
};
socket.onclose = () => {
if (disposed || socketRef.current !== socket) return; // stale event
setStatus("waiting");
timer = window.setTimeout(connect, delayFor(attemptRef.current++));
};
};
connect();
return () => {
disposed = true;
window.clearTimeout(timer);
socketRef.current?.close();
};
}, [url]);
return { status, send: (data: unknown) => socketRef.current?.send(JSON.stringify(data)) };
}
Two details carry most of the correctness. socketRef.current !== socket discards events from sockets you have already replaced. And the attempt counter resets on open, not on connecting — otherwise a connection that opens and immediately fails resets the backoff every time and you are back to hammering.
Resuming, not just reconnecting
Reconnecting gets the pipe back. It does not get the missed data back, and for anything stateful that gap is visible to the user.
The cheap version: have the client remember the last event id it processed and send it on reconnect, so the server can replay from there.
socket.onopen = () => socket.send(JSON.stringify({ resumeFrom: lastEventId }));
The server keeps a bounded buffer of recent events per channel — a few hundred is usually plenty — and replays anything newer. If the requested id has fallen out of the buffer, say so explicitly and let the client fetch a fresh snapshot over HTTP instead of pretending it is up to date.
That last part is the one people skip, and it is the one that produces long-lived, silently stale UIs.
What to show the user
Status deserves to be visible, but not loudly. connecting and waiting in the first few seconds are normal and showing a red banner for them trains people to ignore banners. Wait until you have been out of open for five seconds or so before saying anything, then be specific: "Reconnecting…" while in WAITING, and an explicit "Disconnected — retry" once you have given up.
A state machine makes that trivially expressible, which is the last argument for writing one: the UI copy maps one-to-one onto states you already have.
~/keep-reading
- 8 min readReal-Time Telemetry: Why Polling Lies, and WebSockets Don'tPolling dashboards lie between ticks — I learned that the hard way. Now I push telemetry over WebSockets for sub-second parity across every React client.
- 8 min readHalving WebSocket payloads with MessagePack in Node and ReactWhere JSON over WebSockets actually costs you, what MessagePack does and does not fix, and how to batch high-frequency events so the wire is not the bottleneck.
- 8 min readWebSocket Telemetry at Scale: When One Process Isn't EnoughA single WebSocket server is a weekend project; streaming telemetry to thousands across instances broke for me on streamerOS — Redis pub/sub, rooms, coalescing.
YK
Yaseen Khatib · MERN + AI Architect
Ships autonomous AI products solo — five in the last twelve months. More about Yaseen →
Need an engineer who can build this?
I'm Yaseen Khatib — a Senior Full-Stack AI Engineer (MERN + TypeScript) who ships production AI systems solo. Open to senior and lead roles, remote or on-site.
Get in touch →See what I've shipped
Originally published at yaseenkhatib.streamerosai.com/blog/websocket-reconnect-state-machine-react-node/.
Top comments (0)