Connection Lifecycle, Heartbeats and Reconnection
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. See the section bibliography for the reference material consulted while preparing these pages. |
A WebSocket is a single TCP connection, and a TCP connection can disappear without telling anyone: a phone leaves Wi-Fi, a laptop sleeps, a NAT table entry expires, a load balancer drops an idle flow, a server is redeployed. The client then holds a socket object that looks open and delivers nothing. This page is about the code that sits between the application and the socket and turns that unreliable pipe into a session that survives: the connection states, the heartbeats that detect a dead peer, the reconnect schedule, the resume protocol that fills the gap, and the browser events that tell the client when to wait or hurry.
The page was written against the WHATWG WebSockets living standard with RFC 6455, the ws 8.22.0 server library and the Node.js 24 global WebSocket client, TypeScript 7.0.2, socket.io 4.8.4, ASP.NET Core 10.0 SignalR and XEP-0198 version 1.6.3 (2025-07-28). Everything was checked on 2026-10-24. The examples use the Chatterbox wire protocol of Message Design and Serialization: JSON envelopes with an id, a per-room seq, a hello handshake that can carry resume, a join that can carry since, a presence message that doubles as the application heartbeat, and application close codes in the 4000 range.
All three scenario types need this code. A chat client (Chatterbox) must not lose or duplicate a message when the train enters a tunnel. A videoconference (Huddle) uses its signalling WebSocket to renegotiate calls, so a silent signalling socket means a call that can no longer adapt; see Signaling and Perfect Negotiation. A game (Pixel Arena) treats a reconnect as a rejoin of a live simulation, discussed in Multiplayer Game Architecture. The server half of this page is covered again from the operations side in Proxies, Load Balancers and Deployment and Scaling WebSockets.
The connection as a state machine
A reconnecting client is easiest to reason about, and to test, as an explicit finite state machine. The states below are the ones the example class in this page uses. Every transition has a single cause, and every state has exactly one timer or one event that leaves it.
| State | Meaning | Leaves when |
|---|---|---|
Idle |
Nothing was requested yet. |
The application calls |
Connecting |
A ticket is being fetched and the TCP, TLS and HTTP upgrade handshakes are running. |
The browser fires |
Handshaking |
The socket is open, but the application protocol is not: |
The server answers |
Online |
The session is established. Heartbeats run, the outbox is flushed, the watchdog is armed. |
A close frame arrives, the socket errors, or the watchdog sees no inbound frame for twice the heartbeat interval. |
Backoff |
Waiting for the randomised retry timer. |
The timer elapses, or a signal such as a visible tab or an |
Offline |
The browser reports no network. No attempt is made, because it would fail and lengthen the delay for nothing. |
The |
Suspended |
The page is being hidden for good or put into the back/forward cache. The socket was closed on purpose. |
|
Closed |
Terminal. Either the application asked for it, or the server said that retrying is pointless. |
Never; the application must call |
The state is information for the user interface too: a banner that says "Reconnecting" appears in Backoff and Connecting, input is disabled in Offline, and a "Sign in again" dialog appears in Closed. SignalR exposes the same idea as HubConnectionState with Connected, Reconnecting and Disconnected, and Socket.IO as connect, disconnect and connect_error events plus socket.connected.
Detecting a dead connection
TCP reports a failure only when it tries to send and gets no acknowledgement, which can take many minutes, or when the peer sends a reset. A client that only waits for the browser’s close event can sit on a half-open connection for a long time, so liveness must be tested actively.
Protocol ping and pong
RFC 6455 section 5.5.2 lets either endpoint send a Ping control frame at any time after the handshake, and requires the receiver to answer with a Pong carrying the same application data, as soon as practical. An unsolicited Pong is allowed as a one-way heartbeat (section 5.5.3). A browser answers pings by itself, but the browser WebSocket API has no method to send a ping and no event that reports a received ping or pong. JavaScript in a page cannot see them. Three consequences follow:
-
The server is the one that can use protocol pings. Server libraries expose them: in
ws,socket.ping()and thepongevent; the usual pattern keeps anisAliveflag cleared at each round and terminates sockets that did not answer. Because the browser answers them in the network stack, apongproves the browser process and the network path work, but not that the page’s JavaScript is alive. -
The client cannot use pings to find out that the server vanished. It needs an application-level message.
-
A protocol ping from the server still helps the client indirectly. It is data on the wire, so it resets the idle timers of proxies and load balancers between the two.
Application heartbeats
An application heartbeat is an ordinary message that the application defines. In Chatterbox it is the presence envelope: the server announces heartbeatMs (20000 by default) in its hello reply, the client re-sends its current status every heartbeatMs, and the server answers every presence with an ack. Both sides keep the time of the last frame received, and either closes with 4408 when it has heard nothing for 2 x heartbeatMs. The server also sends a protocol ping every heartbeatMs for the benefit of intermediaries. The ack gives the client a round-trip time for free, which is useful for diagnostics and for Observability.
Socket.IO does the same with its own engine layer: the server sends a ping packet every pingInterval (25000 ms by default) and the client must answer within pingTimeout (20000 ms by default since v4.0.0). SignalR sends keep-alive pings from the client every 15 s (KeepAliveInterval) and treats the server as gone after 30 s of silence (ServerTimeout, recommended to be at least double the keep-alive interval). XMPP uses whitespace keep-alives, XEP-0199 pings, or the acknowledgements of stream management, covered below.
The watchdog should compare timestamps, not count timer ticks. A tab can be throttled or frozen, a laptop can sleep, and an interval timer then fires late and in a burst. The example class stores lastRx = Date.now() on every frame and on each heartbeat tick compares against 2 * heartbeatMs. When it declares the socket dead it does not wait for the close handshake, because a close frame on a half-open connection is never answered: it detaches its own handlers, calls close(4408) (an application code a browser may send) and goes straight to the retry timer.
Idle timeouts in proxies and load balancers
Reverse proxies, load balancers, NAT gateways, corporate firewalls and mobile carriers all keep per-connection state and drop the entries that are idle too long. The heartbeat interval must be shorter than the shortest idle timeout on the path, with enough margin for one lost heartbeat. As a rule of thumb, use a third of the smallest known timeout or less.
| Component | Default idle behaviour | Source of the default |
|---|---|---|
AWS Application Load Balancer |
Closes a connection with no data in either direction for 60 seconds. Configurable from 1 to 4000 seconds with the |
AWS documentation, connection idle timeout |
nginx reverse proxy |
Closes the connection if the proxied server sends no data for 60 seconds ( |
nginx WebSocket proxying guide |
Socket.IO |
|
Socket.IO server options |
SignalR |
|
Microsoft Learn, SignalR configuration |
Chatterbox |
|
Chatterbox protocol v1 |
With an ALB at its default 60 seconds, the Chatterbox heartbeat of 20 seconds leaves room for two lost heartbeats before the balancer would cut the flow. If the balancer idle timeout is raised, keep the heartbeat anyway: it is also what detects half-open connections. AWS also recommends keeping the application’s own idle timeout larger than the balancer’s, to avoid the balancer sending a request into a connection that the application has already closed.
Reconnecting without hurting the server
Exponential backoff with full jitter
A client that retries immediately and forever turns a server restart into a self-inflicted outage: all clients reconnect in the same second, the new process is overloaded, rejects many of them, and they all retry in the same next second. Two ingredients fix it. Exponential backoff makes each failed attempt wait longer than the previous one, and jitter makes clients choose different delays. The AWS Architecture Blog analysis of this problem compares three jittered strategies and concludes that jittered backoff should be a standard approach for remote clients; its "full jitter" formula is
sleep = random_between(0, min(cap, base * 2 ** attempt))
Here base is the first window (1 s in the example), cap bounds the window (30 s), and attempt counts consecutive failures starting at zero. The delay is uniform over the whole window, including values near zero, so the first reconnects after an outage are spread over the first second, the next ones over two seconds, and so on. The blog finds that "equal jitter", which keeps half of the window as a guaranteed minimum, does slightly more work and takes much longer than full jitter, so the example uses full jitter.
Rules that the example class applies:
-
Count consecutive failures, not total reconnects. Reset the counter only after the connection has stayed up for a stable period (30 s in the example, longer than the longest typical server-side rejection), not at the moment the socket opens. A server that accepts the upgrade and then closes immediately (for example during overload) would otherwise keep every client at the shortest delay.
-
Always cap the window. Without a cap, a client that was offline for an hour would wait hours. With a cap, a whole fleet that retries every 30 s on average produces a predictable background load.
-
Never use the same constant delay for different failure causes. Distinguish rate limiting, restarts and rejected credentials, as in the next section.
-
Wake early on good signals. A visible tab, a
pageshowor anonlineevent is evidence that waiting is no longer useful, but the wake-up should itself carry a small random delay if many clients can receive the same signal at once (the example uses 0 to 1 s afteronline).
Libraries differ. Socket.IO reconnects by default (reconnection: true, reconnectionAttempts: Infinity) with reconnectionDelay 1000 ms, doubling up to reconnectionDelayMax 5000 ms, and a randomizationFactor of 0.5 that varies each delay by plus or minus 50 percent. That is jitter around the exponential value, not full jitter, and the 5 s cap is low for a large fleet; raise reconnectionDelayMax for big deployments. SignalR’s withAutomaticReconnect() waits 0, 2, 10 and 30 seconds and then gives up, with no randomisation; the documentation shows a custom IRetryPolicy whose nextRetryDelayInMilliseconds returns Math.random() * 10000 while the outage is younger than 60 seconds, which is jitter without backoff.
The simulation in the examples compares three schedules for 1000 clients that lose the server at the same instant.
Close codes decide the policy
A reconnect loop must not retry blindly. The close code the server sent says what the client should do. Browsers may send only 1000 and the range 3000 to 4999, and 1005, 1006 and 1015 are never on the wire; 1006 is what the browser reports when the connection ended without a close frame. Chatterbox uses these codes, and the example class implements the table.
| Code | Meaning | Client action |
|---|---|---|
1000 from the client |
Normal closure requested by the application, or by |
Do not retry (Closed) or reconnect on |
1001 |
Server going away. |
Retry within about a second, with jitter, without increasing the failure counter. |
1006 |
Abnormal closure, no close frame (network loss, killed process, proxy drop). |
Normal backoff. |
4400, 4413, 4426 |
Protocol error, frame too large, unsupported version. These are bugs or a client that is too old. |
Do not retry. Show an update or error message. |
4401, 4403 |
Unauthorized, forbidden. |
Do not retry with the same credential. Fetch a new ticket or ask the user to sign in again. |
4408 |
Hello or heartbeat timeout. |
Normal backoff. |
4429 |
Rate limited (the server counted too many envelopes). |
Back off with a long minimum (5 s in the example) because immediate retries make the limit worse. |
4503 |
Server restarting. |
Reconnect fast, within about a second, with jitter. The server will be back soon; the other clients are doing the same, so the jitter matters. |
Never put secrets in the close reason: it is limited to 123 bytes, travels in clear text through proxies, and shows up in logs. The authentication side, including one-time tickets, is in Authentication and Authorization, and the code space is defined in The WebSocket Protocol. The browser API details, including why error carries no information, are in The Browser WebSocket API.
Resuming instead of starting over
Reconnecting restores the pipe, not the conversation. Whatever was published while the client was away must be delivered afterwards, and whatever the client sent just before the drop may or may not have arrived. There are two complementary mechanisms, and Chatterbox defines both.
Session resume with a token. The hello reply contains a sessionId. After a reconnect the client sends hello with resume: { sessionId, rooms: { room: lastSeq } }. If the server still has the session (Chatterbox keeps it for 120 s after the disconnect), it answers with resumed: true, then replays every message, edit and react envelope whose seq is greater than the client’s cursor, using the original seq values. Room membership is kept, so the client does not join again.
Cursor-based catch-up. If the session expired, the reply says resumed: false. The client then sends join with since set to its last known seq for each room, and the server replays from that cursor, or the client pages history. This is slower but needs no server-side session at all, and it is the only mechanism that works across a server restart that loses memory.
Both depend on the same property: every room has a strictly increasing seq, and the client stores the highest one it has processed, per room. The client must also ignore anything with a seq it already has, because a replay can overlap with live traffic. See History, Offline Delivery and Moderation for the server storage side and Scaling WebSockets for resuming when sessions live in a shared store rather than in one process.
The outbox: retry with the same id
For the opposite direction the client keeps an outbox. Every envelope that expects an ack (everything except typing and receipt) stays in the outbox until its ack arrives. After a reconnect the client sends the outbox again, in the original order, with the original ids. This is safe because the server stores the result of each (userId, id) pair for at least 24 hours: a repeated id returns the same ack with dup: true, the same seq, and no second broadcast. The pattern is at-least-once delivery on the wire combined with idempotent processing, which behaves like exactly-once for the user.
Three details matter. The id must be created when the user presses send, not when the envelope is written to the socket, otherwise a retry would get a new id and duplicate the message. The outbox needs an ack timeout (10 s in Chatterbox) so that a message sent into a silently dead socket is retried rather than waiting forever. And the outbox must have a bound, or an offline phone fills memory: drop or mark as failed the oldest items and show the user. A durable outbox in IndexedDB survives reloads, at the price of having to expire entries that the server will no longer accept; ordinary typing and receipt events are fire-and-forget and are not queued.
Browser signals
Online and offline events
The window events online and offline fire when the browser gains or loses network access, and navigator.onLine reports the current belief. MDN calls the property inherently unreliable: true only means the device is connected to some network (a LAN without Internet counts, and virtual ethernet adapters of virtualisation software are always connected), false is more trustworthy than true, and the event "shouldn’t be used to determine the availability of a particular website". So use them as hints only:
-
On
online, skip the remaining backoff and connect after a small random delay, because several tabs and devices receive the event together. -
On
offline, cancel a pending retry timer so that no attempt is wasted, but do not close a socket that is open; wait for the heartbeat watchdog to decide, because the event can fire in captive portal and VPN transitions while the socket survives. -
Never refuse to try because
navigator.onLineis false when the user asked to send: queue the message in the outbox and retry when a connection exists.
The Network Information API
navigator.connection (NetworkInformation) offers effectiveType, type and a change event, which in principle tell the application when it moved from Wi-Fi to cellular. MDN marks it as limited availability and not Baseline, the specification is a WICG draft, and the values are estimates. Treat it as an optional signal: when it fires change and the socket is open, send a heartbeat immediately and let the watchdog act on the answer, but never make the reconnect logic depend on it. A path change often kills the TCP connection without any visible event, which is why the heartbeat stays the source of truth.
Page visibility and timer throttling
document.visibilityState is hidden when the tab is in the background, the window is minimised or the screen is off, and the visibilitychange event reports the change. Background pages have their timers throttled: MDN describes budget-based throttling in Chrome and Firefox where timer callbacks run only while a budget regenerates, and lists tabs that use WebSockets or WebRTC among the exemptions, but the rules are heuristic and change between browser versions, and a frozen or discarded tab runs no timers at all. A chat app should therefore:
-
keep the socket open while hidden (users expect notifications), but never trust the interval timers to be punctual: compare timestamps, as the watchdog above does;
-
on
visibilitychangetovisible, run the liveness check immediately and, if the client is in Backoff, reconnect now instead of waiting for the rest of a long delay; -
use the visibility state for presence (
awayafter some time hidden) and to lower the heartbeat frequency only within the idle timeouts discussed above.
The SignalR documentation adds a related warning: browsers with tab freezing or sleeping can close SignalR connections, and the page documents a Web Locks workaround that triggers one of the browser’s no-sleep heuristics. Those heuristics change, so test with the browsers you support.
bfcache: close on pagehide, reopen on pageshow
The back/forward cache keeps a complete page in memory so that the Back button restores it instantly. web.dev explains that browsers may refuse to cache a page that holds open connections such as IndexedDB connections, in-flight requests, and, depending on the browser, open WebSocket or WebRTC connections; at the time of the check, Chrome (as of version 149) and Safari do not block bfcache for open WebSockets, while other browsers do. Because the outcome differs, the portable rule is the one web.dev recommends: close connections in pagehide (or freeze on Chromium) and reopen them in pageshow (or resume). Never use unload for this: it is deprecated and does not fire reliably.
The example class closes the socket with code 1000 on pagehide, enters Suspended, and reconnects on pageshow when event.persisted is true. A normal page load fires pageshow too, but with persisted false and the application starting normally. After a restore the page’s state is exactly as it was when it was frozen, so the resume cursors are still valid: the client sends hello with resume, and if the session has expired in the meantime it falls back to join with since. Closing deliberately also gives the server a clean 1000 close and lets it mark the user offline immediately instead of waiting for the grace period.
How other stacks solve it
Socket.IO connection state recovery. Since Socket.IO 4.6.0 the server option connectionStateRecovery (with maxDisconnectionDuration and skipMiddlewares) stores the session id, the rooms and socket.data for a limited time and the packets that were emitted to the socket meanwhile. A client that reconnects with its stored offset gets a socket.recovered flag set to true on both sides and receives the missed events; otherwise recovered is false and the application must resynchronise. Only unexpected disconnections are stored (an explicit socket.disconnect() is not), the server must have sent at least one event so that the client has an offset, the built-in in-memory adapter, the Redis Streams adapter and the MongoDB adapter support it, and the classic Redis pub/sub adapter does not because it cannot persist packets. The documentation states that recovery will not always succeed, so the client still needs a fallback, which is the same position as Chatterbox’s resumed: false path. See Socket.IO (Node.js) and Socket.IO Protocol and Clients.
SignalR automatic reconnect and stateful reconnect. The JavaScript client does not reconnect unless withAutomaticReconnect() is called; the default schedule is 0, 2, 10 and 30 seconds, after which the connection moves to Disconnected and fires onclose. During the attempts the state is Reconnecting, onreconnecting and onreconnected fire, and because the server sees a new connection, onreconnected receives a new connection id. Automatic reconnect does not retry the first start(); that failure must be handled by the application. Stateful reconnect, available since .NET 8, buffers messages on both sides so that a short outage does not lose them: the hub endpoint opts in with AllowStatefulReconnects = true, the client calls withStatefulReconnect(), and the buffer size (StatefulReconnectBufferSize on the server, bufferSize in the client options) defaults to 100,000 bytes. It does not survive a server restart, because the buffer lives in the server process. See SignalR.
XMPP stream management (XEP-0198). XEP-0198, with status Stable, adds acknowledgements and resumption to an XMPP stream. The client sends <enable resume='true'/>, the server answers <enabled id='...' resume='true' max='300'/>, and from then on each side counts the stanzas it has handled in an h value, exchanged with <r/> (request) and <a h='...'/> (answer). After a lost connection the client reconnects, authenticates, and sends <resume previd='...' h='...'/>; the server answers <resumed .../> with its own h, and each side resends the stanzas the other has not handled. If resumption fails the server answers <failed/>, and the client binds a new resource and catches up with message archives. The counters play the role of Chatterbox’s seq cursors and ack envelopes, in both directions at once. See XMPP Fundamentals and XMPP in the Browser.
Examples
The three files below run together. The test server is a deliberately small Chatterbox v1 server written with ws that keeps sessions for a configurable time, replays missed messages, remembers ids for idempotency and can misbehave on demand: drop every TCP connection without a close frame, close with an application code, go silent (a half-open connection), and swallow acknowledgements. The client is the complete reconnecting socket. The demo drives both through the scenarios of this page, with fake EventTarget objects standing in for document, window and navigator, so that visibility, pagehide, pageshow, online and offline can be triggered from a script.
Project files
{
"name": "chatterbox-reconnect-demo",
"private": true,
"type": "module",
"scripts": {
"build": "tsc",
"demo": "tsc && node dist/demo.js",
"simulate": "tsc && node dist/simulate-backoff.js"
},
"dependencies": { "ws": "8.22.0" },
"devDependencies": { "@types/node": "24.19.2", "@types/ws": "8.18.1", "typescript": "7.0.2" }
}
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2023", "DOM"],
"types": ["node"],
"strict": true,
"outDir": "dist",
"rootDir": "src",
"skipLibCheck": true
},
"include": ["src"]
}
Source: TypeScript: TSConfig reference
A reconnecting Chatterbox client
The class has no dependencies. In a real page, pass documentTarget: document (for visibilitychange and visibilityState) and windowTarget: window (for pagehide, pageshow, online, offline and navigator.onLine); the demo passes fake EventTarget objects instead. connect() fetches a one-time ticket, opens the socket with the chatterbox.v1 subprotocol, sends hello (with resume when it has a session) and becomes Online when the reply arrives.
export type State =
| "idle"
| "connecting"
| "handshaking"
| "online"
| "backoff"
| "offline"
| "suspended"
| "closed";
export interface Envelope {
v: 1;
type: string;
id: string;
room: string | null;
ts: number;
payload: Record<string, any>;
}
export interface Options {
url: string;
getTicket?: () => Promise<string | undefined>;
baseMs?: number;
capMs?: number;
stableMs?: number;
ackTimeoutMs?: number;
/** Where visibilitychange is dispatched and visibilityState is read: document in a page. */
documentTarget?: EventTarget & { visibilityState?: string };
/** Where pagehide, pageshow, online and offline are dispatched: window in a page. */
windowTarget?: EventTarget & { navigator?: { onLine: boolean } };
random?: () => number;
}
const NO_ACK = new Set(["typing", "receipt"]);
const FATAL = new Set([4400, 4401, 4403, 4413, 4426]);
let counter = 0;
const newId = () => `c${Date.now().toString(36)}${(counter++).toString(36)}${Math.random().toString(36).slice(2, 8)}`;
export class ReconnectingChatterboxSocket {
state: State = "idle";
onState: (s: State, why: string) => void = () => {};
onEnvelope: (e: Envelope) => void = () => {};
private ws?: WebSocket;
private attempt = 0;
private heartbeatMs = 20_000;
private lastRx = 0;
private sessionId?: string;
private hadSession = false;
private ackTimer?: ReturnType<typeof setTimeout>;
private readonly lastSeq = new Map<string, number>();
private readonly rooms = new Set<string>();
private readonly outbox: { env: Envelope; sentAt: number }[] = [];
private timers = new Set<ReturnType<typeof setTimeout>>();
private hbTimer?: ReturnType<typeof setInterval>;
private stableTimer?: ReturnType<typeof setTimeout>;
private backoffTimer?: ReturnType<typeof setTimeout>;
private wantOpen = false;
private readonly o: Required<Pick<Options, "baseMs" | "capMs" | "stableMs" | "ackTimeoutMs" | "random">> & Options;
constructor(options: Options) {
this.o = { baseMs: 1000, capMs: 30_000, stableMs: 30_000, ackTimeoutMs: 10_000, random: Math.random, ...options };
this.o.documentTarget?.addEventListener("visibilitychange", () => this.onVisibility());
const w = this.o.windowTarget;
w?.addEventListener("pagehide", () => this.suspend("pagehide"));
w?.addEventListener("pageshow", (e) => {
if ((e as PageTransitionEvent).persisted && this.wantOpen) this.connectNow("pageshow");
});
w?.addEventListener("online", () => this.onOnline());
w?.addEventListener("offline", () => this.onOffline());
}
connect(): void {
this.wantOpen = true;
this.connectNow("connect()");
}
close(): void {
this.wantOpen = false;
this.teardown(1000, "client close");
this.set("closed", "close()");
}
join(room: string): void {
this.rooms.add(room);
this.send("join", room, { since: this.lastSeq.get(room) ?? null });
}
/** Queues the envelope. Anything that needs an ack stays in the outbox until the ack arrives. */
send(type: string, room: string | null, payload: Record<string, any>): string {
const env: Envelope = { v: 1, type, id: newId(), room, ts: 0, payload };
if (NO_ACK.has(type)) {
if (this.state === "online") this.ws!.send(JSON.stringify(env));
return env.id;
}
this.outbox.push({ env, sentAt: 0 });
this.flush();
return env.id;
}
get pending(): number {
return this.outbox.length;
}
// ---- connection management ----
private async connectNow(why: string): Promise<void> {
if (!this.wantOpen || this.state === "connecting" || this.state === "handshaking" || this.state === "online") return;
clearTimeout(this.backoffTimer);
this.set("connecting", why);
let ticket: string | undefined;
try {
ticket = await this.o.getTicket?.();
} catch {
return this.scheduleRetry("ticket failed");
}
const url = new URL(this.o.url);
if (ticket) url.searchParams.set("ticket", ticket);
const ws = new WebSocket(url, "chatterbox.v1");
this.ws = ws;
ws.addEventListener("open", () => {
if (this.ws !== ws) return;
this.set("handshaking", "socket open");
const hello: Record<string, any> = { client: "web/1.4" };
if (this.sessionId) hello.resume = { sessionId: this.sessionId, rooms: Object.fromEntries(this.lastSeq) };
ws.send(JSON.stringify({ v: 1, type: "hello", id: newId(), room: null, ts: 0, payload: hello }));
this.lastRx = Date.now();
// The server closes with 4408 when hello does not arrive in 5 s, we give up on a missing reply in 5 s too.
this.after(5000, () => this.ws === ws && this.state === "handshaking" && this.abandon(4408, "hello reply timeout"));
});
ws.addEventListener("message", (ev) => this.onMessage(ws, String(ev.data)));
ws.addEventListener("close", (ev) => {
if (this.ws === ws) this.onClose(ev.code, ev.reason);
});
ws.addEventListener("error", () => {
// The browser gives no detail here; the close event that follows carries the code.
});
}
private onMessage(ws: WebSocket, data: string): void {
if (this.ws !== ws) return;
this.lastRx = Date.now();
const env = JSON.parse(data) as Envelope;
if (env.type === "hello") return this.onHello(env);
if (env.type === "ack" || env.type === "error") {
const ref = env.payload.ref as string | null;
const i = this.outbox.findIndex((x) => x.env.id === ref);
if (i >= 0 && (env.type === "ack" || env.payload.retryable === false)) this.outbox.splice(i, 1);
if (env.type === "ack" && env.room && typeof env.payload.seq === "number") this.bump(env.room, env.payload.seq);
} else if (env.room && typeof env.payload.seq === "number") {
const known = this.lastSeq.get(env.room) ?? 0;
if (env.payload.seq <= known) return; // replay overlap, already delivered
this.bump(env.room, env.payload.seq);
}
this.onEnvelope(env);
}
private onHello(env: Envelope): void {
const p = env.payload;
this.sessionId = p.sessionId;
const hadSession = this.hadSession;
this.hadSession = true;
this.heartbeatMs = p.heartbeatMs;
this.set("online", p.resumed ? "hello (resumed)" : "hello (fresh session)");
if (!p.resumed && hadSession) {
// The server forgot us: join again from our cursors and let it replay what we missed.
for (const room of this.rooms) {
if (this.outbox.some((x) => x.env.type === "join" && x.env.room === room)) continue;
this.outbox.unshift({ env: { v: 1, type: "join", id: newId(), room, ts: 0, payload: { since: this.lastSeq.get(room) ?? null } }, sentAt: 0 });
}
}
this.flush(true);
clearInterval(this.hbTimer);
this.hbTimer = setInterval(() => this.heartbeat(), this.heartbeatMs);
// Reset the attempt counter only after the connection proved stable.
clearTimeout(this.stableTimer);
this.stableTimer = setTimeout(() => (this.attempt = 0), this.o.stableMs);
}
private heartbeat(): void {
if (this.state !== "online") return;
if (Date.now() - this.lastRx > 2 * this.heartbeatMs) return this.abandon(4408, "no frame for 2 heartbeats");
this.ws!.send(JSON.stringify({ v: 1, type: "presence", id: newId(), room: null, ts: 0, payload: { status: "online" } }));
}
private flush(all = false): void {
if (this.state !== "online") return;
const now = Date.now();
for (const item of this.outbox) {
const due = item.sentAt === 0 || all || now - item.sentAt >= this.o.ackTimeoutMs;
if (!due) continue;
item.sentAt = now;
this.ws!.send(JSON.stringify(item.env)); // the same id every time
}
clearTimeout(this.ackTimer);
this.ackTimer = setTimeout(() => this.flush(), this.o.ackTimeoutMs);
}
private onClose(code: number, reason: string): void {
clearInterval(this.hbTimer);
clearTimeout(this.stableTimer);
this.ws = undefined;
if (!this.wantOpen) return;
if (FATAL.has(code)) {
this.wantOpen = false;
return this.set("closed", `fatal close ${code} ${reason}`);
}
if (code === 4503 || code === 1001) return this.scheduleRetry(`close ${code}`, { fast: true });
if (code === 4429) return this.scheduleRetry(`close ${code}`, { minMs: 5000 });
this.scheduleRetry(`close ${code}`);
}
/** Half-open or silent socket: do not wait for the close handshake, it may never complete. */
private abandon(code: number, why: string): void {
this.teardown(code, why);
this.scheduleRetry(why);
}
private teardown(code: number, reason: string): void {
const ws = this.ws;
this.ws = undefined;
clearInterval(this.hbTimer);
clearTimeout(this.stableTimer);
clearTimeout(this.ackTimer);
for (const t of this.timers) clearTimeout(t);
this.timers.clear();
try {
ws?.close(code, reason);
} catch {
/* already closing */
}
}
// ---- retry policy ----
/** Full jitter: a random delay between 0 and min(cap, base * 2^attempt). */
nextDelay(opts: { fast?: boolean; minMs?: number } = {}): number {
const ceiling = opts.fast ? 1000 : Math.min(this.o.capMs, this.o.baseMs * 2 ** this.attempt);
return Math.max(opts.minMs ?? 0, Math.floor(this.o.random() * ceiling));
}
private scheduleRetry(why: string, opts: { fast?: boolean; minMs?: number } = {}): void {
if (!this.wantOpen) return;
if (this.o.windowTarget?.navigator?.onLine === false) return this.set("offline", `${why}; waiting for online`);
const delay = this.nextDelay(opts);
if (!opts.fast) this.attempt++;
this.set("backoff", `${why}; retry in ${delay} ms`);
clearTimeout(this.backoffTimer);
this.backoffTimer = setTimeout(() => this.connectNow("backoff elapsed"), delay);
}
// ---- page and network signals ----
private onVisibility(): void {
if (this.o.documentTarget?.visibilityState !== "visible" || !this.wantOpen) return;
// Timers were throttled while hidden: judge the socket by timestamps, not by missed ticks.
if (this.state === "online" && Date.now() - this.lastRx > 2 * this.heartbeatMs) this.abandon(4408, "stale after hidden");
else if (this.state === "backoff" || this.state === "suspended") this.connectNow("visible again");
}
private onOffline(): void {
// The event is a hint, not proof. An open socket keeps running and the watchdog decides;
// a pending retry is dropped so that no attempt is wasted while there is no network.
if (this.state !== "backoff") return;
clearTimeout(this.backoffTimer);
this.set("offline", "offline event");
}
private onOnline(): void {
if (!this.wantOpen) return;
if (this.state === "offline" || this.state === "backoff") {
clearTimeout(this.backoffTimer);
// A small random delay stops every tab on the network from reconnecting in the same instant.
this.backoffTimer = setTimeout(() => this.connectNow("online event"), Math.floor(this.o.random() * 1000));
}
}
private suspend(why: string): void {
if (!this.wantOpen) return;
this.teardown(1000, why); // 1000 is allowed in a browser; the page may enter the bfcache
this.set("suspended", why);
}
// ---- helpers ----
private bump(room: string, seq: number): void {
if (seq > (this.lastSeq.get(room) ?? 0)) this.lastSeq.set(room, seq);
}
private after(ms: number, fn: () => void): void {
const t = setTimeout(() => {
this.timers.delete(t);
fn();
}, ms);
this.timers.add(t);
}
private set(s: State, why: string): void {
this.state = s;
this.onState(s, why);
}
}
Source: WHATWG WebSockets Standard
Source: MDN: WebSocket
A Node server that drops connections on demand
The server reproduces the part of Chatterbox v1 that matters for reconnection: hello with resume, join with since, message with idempotent acks, presence acks, server pings and the 4408 timeouts. It binds to a random port, so several runs never collide. dropAll() calls terminate(), which destroys the TCP socket without a close frame, the client sees code 1006; setMuted(true) makes the server ignore and send nothing while keeping the socket open, which is what a half-open connection looks like from the client.
import { WebSocketServer, WebSocket } from "ws";
import { randomBytes } from "node:crypto";
interface Session { userId: string; rooms: Set<string>; socket?: WebSocket; expires: number }
interface Stored { seq: number | null; room: string | null }
export interface TestServerOptions {
heartbeatMs?: number;
sessionTtlMs?: number;
}
/** A tiny Chatterbox v1 server that can misbehave on demand. */
export async function startServer(opts: TestServerOptions = {}) {
const heartbeatMs = opts.heartbeatMs ?? 200;
const sessionTtlMs = opts.sessionTtlMs ?? 120_000;
const wss = new WebSocketServer({ port: 0, handleProtocols: (p) => (p.has("chatterbox.v1") ? "chatterbox.v1" : false) });
await new Promise((r) => wss.once("listening", r));
const port = (wss.address() as { port: number }).port;
const sessions = new Map<string, Session>();
const log = new Map<string, any[]>(); // room -> message envelopes in seq order
const seen = new Map<string, Stored>(); // idempotency: client id -> original ack data
const stats = { connections: 0, messageEnvelopes: 0, duplicates: 0, helloResumed: 0, helloFresh: 0 };
let muted = false; // true simulates a half-open connection: nothing in, nothing out
let swallowAcks = 0; // process the next N messages but never send their acks
const sid = () => "s_" + randomBytes(8).toString("hex");
const out = (ws: WebSocket, type: string, room: string | null, payload: object) => {
if (!muted && ws.readyState === ws.OPEN) ws.send(JSON.stringify({ v: 1, type, id: sid(), room, ts: Date.now(), payload }));
};
const nextSeq = (room: string) => (log.get(room)?.length ?? 0) + 1;
const append = (room: string, from: string, id: string, text: string) => {
const env = { v: 1, type: "message", id, room, ts: Date.now(), payload: { from: { userId: from }, text, seq: nextSeq(room) } };
log.set(room, [...(log.get(room) ?? []), env]);
for (const s of sessions.values()) if (s.rooms.has(room) && s.socket) out(s.socket, "message", room, env.payload);
return env.payload.seq;
};
wss.on("connection", (ws) => {
stats.connections++;
let session: Session | undefined;
let lastRx = Date.now();
const helloTimer = setTimeout(() => ws.close(4408, "hello timeout"), 5000);
const beat = setInterval(() => {
if (muted) return;
if (Date.now() - lastRx > 2 * heartbeatMs) return ws.close(4408, "heartbeat timeout");
ws.ping();
}, heartbeatMs);
ws.on("close", () => {
clearTimeout(helloTimer);
clearInterval(beat);
if (session && session.socket === ws) { session.socket = undefined; session.expires = Date.now() + sessionTtlMs; }
});
ws.on("message", (raw) => {
if (muted) return;
lastRx = Date.now();
const env = JSON.parse(String(raw));
const ack = (extra: object = {}, seq: number | null = null, dup = false) =>
out(ws, "ack", env.room, { ref: env.id, ok: true, seq, dup, result: {}, ...extra });
if (env.type === "hello") {
clearTimeout(helloTimer);
const r = env.payload.resume;
const old = r && sessions.get(r.sessionId);
const resumed = !!old && old.expires > Date.now();
const me: Session = resumed ? old : { userId: "u_" + randomBytes(3).toString("hex"), rooms: new Set(), expires: 0 };
session = me;
const id = resumed ? r.sessionId : sid();
sessions.set(id, me);
me.socket = ws;
resumed ? stats.helloResumed++ : stats.helloFresh++;
out(ws, "hello", null, { ref: env.id, userId: me.userId, name: "demo", sessionId: id, heartbeatMs, maxFrameBytes: 16384, resumed, rooms: [...me.rooms] });
if (resumed) for (const [room, last] of Object.entries<number>(r.rooms ?? {})) {
for (const m of log.get(room) ?? []) if (m.payload.seq > last) out(ws, "message", room, m.payload);
}
return;
}
if (!session) return ws.close(4400, "hello first");
if (env.type === "join") {
session.rooms.add(env.room);
const since: number | null = env.payload.since;
ack({ result: { members: [], lastSeq: nextSeq(env.room) - 1 } });
for (const m of log.get(env.room) ?? []) if (since !== null && m.payload.seq > since) out(ws, "message", env.room, m.payload);
} else if (env.type === "message") {
stats.messageEnvelopes++;
const prior = seen.get(env.id);
if (prior) { stats.duplicates++; return ack({}, prior.seq, true); }
const seq = append(env.room, session.userId, env.id, env.payload.text);
seen.set(env.id, { seq, room: env.room });
if (swallowAcks > 0) { swallowAcks--; return; }
ack({}, seq);
} else if (env.type === "presence") {
ack();
}
});
});
return {
port, stats, url: `ws://127.0.0.1:${port}/ws`,
/** Kill every TCP connection without a close frame (the client sees 1006). */
dropAll: () => wss.clients.forEach((c) => c.terminate()),
/** Close every socket with an application code. */
closeAll: (code: number, reason = "") => wss.clients.forEach((c) => c.close(code, reason)),
setMuted: (m: boolean) => { muted = m; },
swallowNextAcks: (n: number) => { swallowAcks = n; },
/** A message from "someone else", delivered live or replayed later. */
inject: (room: string, text: string) => append(room, "u_other", sid(), text),
stop: () => new Promise<void>((r) => { wss.clients.forEach((c) => c.terminate()); wss.close(() => r()); }),
};
}
Source: ws: API documentation
The demo: seven failures in a row
import assert from "node:assert/strict";
import { startServer } from "./chatterbox-test-server.js";
import { ReconnectingChatterboxSocket, type Envelope } from "./reconnecting-chatterbox-socket.js";
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const t0 = Date.now();
const stamp = () => String(Date.now() - t0).padStart(5);
async function until(cond: () => boolean, what: string, ms = 5000) {
const end = Date.now() + ms;
while (!cond()) {
if (Date.now() > end) throw new Error("timeout waiting for " + what);
await sleep(20);
}
}
const server = await startServer({ heartbeatMs: 200, sessionTtlMs: 1500 });
// Fake page and network signal sources, so the browser-only code paths run under Node.
const doc = Object.assign(new EventTarget(), { visibilityState: "visible" });
const win = Object.assign(new EventTarget(), { navigator: { onLine: true } });
const received: Envelope[] = [];
const client = new ReconnectingChatterboxSocket({ url: server.url, baseMs: 100, capMs: 800, stableMs: 600, ackTimeoutMs: 1000, documentTarget: doc, windowTarget: win });
client.onState = (s, why) => console.log(`${stamp()} ms state=${s.padEnd(10)} ${why}`);
client.onEnvelope = (e) => { if (e.type === "message") received.push(e); };
const texts = () => received.map((e) => e.payload.text);
console.log("-- 1. connect, join, send");
client.connect();
client.join("general");
client.send("message", "general", { text: "one", replyTo: null, attachments: [] });
await until(() => client.state === "online" && client.pending === 0, "first ack");
console.log("-- 2. TCP dropped, a message arrives meanwhile, session resumed");
server.dropAll();
server.inject("general", "missed while away");
await until(() => client.state === "online" && texts().includes("missed while away"), "resume replay");
assert.equal(server.stats.helloResumed, 1);
console.log("-- 3. ack lost with the connection: retry with the same id, no duplicate");
server.swallowNextAcks(1);
client.send("message", "general", { text: "two", replyTo: null, attachments: [] });
await sleep(100);
server.dropAll();
await until(() => client.state === "online" && client.pending === 0, "dup ack");
assert.equal(server.stats.duplicates, 1);
assert.equal(texts().filter((t) => t === "two").length, 1);
console.log("-- 4. half-open connection: server goes silent, watchdog notices");
server.setMuted(true);
await until(() => client.state === "backoff", "watchdog", 3000);
server.setMuted(false);
await until(() => client.state === "online", "recovery after watchdog");
console.log("-- 5. close-code policy: 4503 retries fast, 4429 waits at least 5 s (a visible tab skips the wait)");
server.closeAll(4503, "restarting");
await until(() => client.state === "backoff", "4503 backoff");
await until(() => client.state === "online", "4503 reconnect fast");
server.closeAll(4429, "slow down");
await until(() => client.state === "backoff", "4429 backoff");
doc.dispatchEvent(new Event("visibilitychange"));
await until(() => client.state === "online", "wake on visibility");
console.log("-- 5b. offline event: no attempts while offline, immediate after online");
win.navigator.onLine = false;
const conns = server.stats.connections;
server.dropAll();
await until(() => client.state === "offline", "offline state");
await sleep(500);
assert.equal(server.stats.connections, conns, "no connection attempt while offline");
win.navigator.onLine = true;
win.dispatchEvent(new Event("online"));
await until(() => client.state === "online", "online event reconnect");
console.log("-- 6. session expired on the server: fresh hello, join with since fills the gap");
server.inject("general", "before expiry");
await until(() => texts().includes("before expiry"), "live message");
server.setMuted(false);
doc.visibilityState = "hidden";
win.dispatchEvent(new Event("pagehide"));
await until(() => client.state === "suspended", "pagehide suspend");
server.inject("general", "while suspended");
await sleep(1700); // longer than sessionTtlMs
doc.visibilityState = "visible";
win.dispatchEvent(Object.assign(new Event("pageshow"), { persisted: true }));
await until(() => client.state === "online" && texts().includes("while suspended"), "since replay");
assert.ok(server.stats.helloFresh >= 2, "second fresh hello");
assert.deepEqual(texts().filter((t) => t === "while suspended").length, 1);
console.log("-- 7. unauthorized close is fatal");
server.closeAll(4401, "token expired");
await until(() => client.state === "closed", "fatal close");
await sleep(400);
const finalConns = server.stats.connections;
await sleep(400);
assert.equal(server.stats.connections, finalConns, "no retry after 4401");
console.log("\nmessages seen by the UI:", texts());
console.log("server stats:", server.stats);
await server.stop();
console.log("all scenarios passed");
Source: MDN: visibilitychange event
Source: web.dev: Back/forward cache
Running it with npm install, npx tsc and node dist/demo.js prints a timeline like the one below (the exact delays differ on each run because of the jitter):
-- 1. connect, join, send
1 ms state=connecting connect()
14 ms state=handshaking socket open
15 ms state=online hello (fresh session)
-- 2. TCP dropped, a message arrives meanwhile, session resumed
23 ms state=backoff close 1006; retry in 72 ms
96 ms state=connecting backoff elapsed
97 ms state=handshaking socket open
97 ms state=online hello (resumed)
-- 3. ack lost with the connection: retry with the same id, no duplicate
208 ms state=backoff close 1006; retry in 38 ms
247 ms state=connecting backoff elapsed
248 ms state=handshaking socket open
249 ms state=online hello (resumed)
-- 4. half-open connection: server goes silent, watchdog notices
850 ms state=backoff no frame for 2 heartbeats; retry in 58 ms
908 ms state=connecting backoff elapsed
909 ms state=handshaking socket open
910 ms state=online hello (resumed)
-- 5. close-code policy: 4503 retries fast, 4429 waits at least 5 s (a visible tab skips the wait)
920 ms state=backoff close 4503; retry in 735 ms
1655 ms state=connecting backoff elapsed
1657 ms state=handshaking socket open
1657 ms state=online hello (resumed)
1674 ms state=backoff close 4429; retry in 5000 ms
1694 ms state=connecting visible again
1695 ms state=handshaking socket open
1696 ms state=online hello (resumed)
-- 5b. offline event: no attempts while offline, immediate after online
1715 ms state=offline close 1006; waiting for online
2749 ms state=connecting online event
2751 ms state=handshaking socket open
2751 ms state=online hello (resumed)
-- 6. session expired on the server: fresh hello, join with since fills the gap
2782 ms state=suspended pagehide
4483 ms state=connecting pageshow
4484 ms state=handshaking socket open
4485 ms state=online hello (fresh session)
-- 7. unauthorized close is fatal
4505 ms state=closed fatal close 4401 token expired
messages seen by the UI: [
'one',
'missed while away',
'two',
'before expiry',
'while suspended'
]
server stats: {
connections: 8,
messageEnvelopes: 3,
duplicates: 1,
helloResumed: 6,
helloFresh: 2
}
all scenarios passed
Reading the output: in scenario 3 the server processed message "two" and then lost the connection before the ack could be sent, so the client resent it after resuming; the server answered with dup: true and the UI shows "two" once. In scenario 4 the server stopped answering while the socket stayed open, and the watchdog gave up after two heartbeat intervals. In scenario 5 the 4503 delay is below one second, the 4429 minimum is five seconds but a visibilitychange event skipped the wait, and no connection attempt reached the server during the offline period. In scenario 6 the session expired while the page was suspended, so the hello reply said resumed: false and the join with since brought back the missed message; scenario 7 shows that 4401 ends the loop.
Simulating a thundering herd
The script prints the delays 1000 clients would choose after losing the server at the same instant, for three policies, and then counts how many connection attempts hit the server in each 100 ms slot during a 10 second outage when every attempt fails.
// Prints the reconnect delays that 1000 clients would pick after the same outage.
const CLIENTS = 1000;
const BASE_MS = 1000;
const CAP_MS = 30_000;
type Policy = (attempt: number) => number;
const policies: Record<string, Policy> = {
"no backoff (fixed 1 s)": () => BASE_MS,
"exponential, no jitter": (n) => Math.min(CAP_MS, BASE_MS * 2 ** n),
"full jitter": (n) => Math.random() * Math.min(CAP_MS, BASE_MS * 2 ** n),
};
function histogram(values: number[], bucketMs: number, buckets: number): number[] {
const h = new Array<number>(buckets).fill(0);
for (const v of values) h[Math.min(buckets - 1, Math.floor(v / bucketMs))]++;
return h;
}
for (const [name, policy] of Object.entries(policies)) {
console.log(`\n${name}`);
for (const attempt of [0, 1, 2, 3, 4]) {
const delays = Array.from({ length: CLIENTS }, () => policy(attempt)).sort((a, b) => a - b);
const ceiling = Math.min(CAP_MS, BASE_MS * 2 ** attempt);
const h = histogram(delays, ceiling / 10, 10);
const peak = Math.max(...h);
console.log(
`attempt ${attempt}: window ${ceiling} ms, min ${delays[0].toFixed(0)}, median ${delays[CLIENTS / 2].toFixed(0)}, max ${delays[CLIENTS - 1].toFixed(0)}, busiest tenth of the window ${peak} of ${CLIENTS} clients`,
);
}
}
// The server stays down for 10 s. Every client keeps retrying; count the attempts that reach the server per 100 ms slot.
const OUTAGE_MS = 10_000;
for (const [name, policy] of Object.entries(policies)) {
const slots = new Array<number>(OUTAGE_MS / 100).fill(0);
let total = 0;
for (let c = 0; c < CLIENTS; c++) {
let t = 0;
for (let attempt = 0; ; attempt++) {
t += policy(attempt);
if (t >= OUTAGE_MS) break;
slots[Math.floor(t / 100)]++;
total++;
}
}
console.log(`\n${name}: ${total} attempts in 10 s, peak ${Math.max(...slots)} in one 100 ms slot`);
}
The last three lines of the output (one run) show the effect of jitter:
no backoff (fixed 1 s): 9000 attempts in 10 s, peak 1000 in one 100 ms slot
exponential, no jitter: 3000 attempts in 10 s, peak 1000 in one 100 ms slot
full jitter: 3957 attempts in 10 s, peak 156 in one 100 ms slot
A fixed retry interval and an exponential interval without jitter both produce peaks of 1000 attempts in a single 100 ms slot, which means the whole fleet hits the server at once, every time. Full jitter reduces the peak to roughly a sixth of the fleet at the very start and spreads the rest evenly. The initial burst is higher than average because the first window is only one second wide; a longer base, or a randomised first delay that is wider than the following ones, flattens it further.
The same ideas in the SignalR JavaScript client
For comparison, the equivalent policy in SignalR combines a custom retry policy with full jitter and stateful reconnect. The client is a sketch in the style of the documentation; it needs a hub with AllowStatefulReconnects enabled.
import * as signalR from "@microsoft/signalr";
const connection = new signalR.HubConnectionBuilder()
.withUrl("/chathub")
.withStatefulReconnect({ bufferSize: 100000 })
.withAutomaticReconnect({
nextRetryDelayInMilliseconds: (ctx) => {
if (ctx.elapsedMilliseconds > 5 * 60 * 1000) return null; // give up after five minutes
const window = Math.min(30000, 1000 * 2 ** ctx.previousRetryCount);
return Math.random() * window; // full jitter
},
})
.build();
connection.onreconnecting(() => console.log("state: Reconnecting"));
connection.onreconnected((id) => console.log("state: Connected again", id));
connection.onclose(() => console.log("state: Disconnected, show the sign-in dialog"));
await connection.start();
Socket.IO with connection state recovery
// server.js
import { createServer } from "node:http";
import { Server } from "socket.io";
const io = new Server(createServer().listen(3000), {
pingInterval: 20000,
pingTimeout: 20000,
connectionStateRecovery: { maxDisconnectionDuration: 2 * 60 * 1000, skipMiddlewares: false },
});
io.on("connection", (socket) => {
console.log(socket.recovered ? "recovered session" : "new session", socket.id);
socket.on("message", (m) => io.to("general").emit("message", m));
socket.join("general");
});
// client.js
import { io } from "socket.io-client";
const socket = io("http://localhost:3000", { reconnectionDelay: 1000, reconnectionDelayMax: 30000, randomizationFactor: 0.5 });
socket.on("connect", () => console.log(socket.recovered ? "missed events follow" : "fresh state, reload history"));
Common errors and pitfalls
-
Heartbeat longer than the idle timeout. The connection works in development and dies every 60 s behind the load balancer. Set the interval to a third of the smallest timeout and test through the real proxy.
-
Retrying immediately and forever. A restart becomes a retry storm. Use backoff, full jitter and a cap.
-
Resetting the failure counter on
open. A server that accepts and immediately closes keeps every client at the shortest delay. Reset after the connection has been stable. -
Retrying fatal codes. A client looping on 4401 or 4426 hammers the server and never recovers. Stop and ask for a new credential or a reload.
-
A new id on every retry. The server cannot deduplicate, and the user sees the message twice. Generate the id once, store it with the outbox item.
-
Counting interval ticks. Throttled and frozen tabs break tick-based watchdogs. Compare timestamps.
-
Waiting for the close handshake on a dead socket. It may never finish. Detach the handlers and move on.
-
Trusting
navigator.onLine. It can be true without Internet and false while the socket works. Use it only as a hint. -
Two sockets at once. A late
closeevent of an old socket can trigger a second reconnect. The example checks that the socket that fired an event is still the current one. -
Forgetting
pagehide. An open socket can keep a page out of the back/forward cache in some browsers, and the server learns about the departure only after the heartbeat timeout. -
Replay without deduplication. Replayed envelopes overlap with live ones. Ignore every
seqat or below the stored cursor. -
Unbounded outbox. Cap it and surface failures.
-
Re-authentication ignored. Tickets are one-time, so the reconnect must fetch a fresh one on every attempt, as
getTicketdoes in the example.
Security notes
-
Reconnection multiplies authentication traffic: use one-time tickets with a short life, and rate-limit ticket issuing as well as handshakes. See Authentication and Authorization.
-
A resumed session must be bound to the authenticated user. A
sessionIdis a bearer secret; it must be unguessable, tied to the user id, expire quickly, and be rejected if the new connection authenticates as another user. -
Resume and recovery skip work the server did on the first connection. Socket.IO warns that
skipMiddlewares: truecan let a user who was blocked during the outage return without validation, so decide deliberately, and re-check authorisation on rooms after a resume. -
Cap replay size. A client can claim
since: 0for a huge room; limit what a replay sends and fall back to pagedhistory. -
Honour
4429and server-side rate limits for reconnect attempts too; a buggy client that reconnects in a tight loop is indistinguishable from an attack. See Security.
Version and specification notes
-
RFC 6455 (2011) defines ping, pong and the close handshake. The WHATWG WebSockets Standard defines the browser API; it exposes
close(code, reason)with codes 1000 or 3000 to 4999, and offers no ping method. -
Socket.IO 4.0.0 changed the default
pingTimeoutto 20000 ms, and connection state recovery was added in 4.6.0. -
SignalR stateful reconnect is available from .NET 8; automatic reconnect with the default 0, 2, 10 and 30 second schedule is documented for ASP.NET Core 3.1 and later.
-
XEP-0198 stream management is Stable at version 1.6.3, dated 2025-07-28.
-
The bfcache guidance changes quickly: web.dev notes browser-specific behaviour for open WebSockets, so keep the
pagehideandpageshowhandlers regardless.