Resuming WebSocket Sessions After Reconnect #

The reconnect works. The socket comes back in under a second, the status pill flips to “Live”, and the user still sees a stale board — because the three updates that arrived during the gap were published to a socket that no longer existed. Worse, some clients refetch everything on every reconnect, so a flaky train journey turns into forty full snapshot downloads and a visibly janky UI.

Reconnecting is the easy half. Resuming — arriving back with exactly the messages you missed, once each — is the half that makes real-time feel reliable.

Root cause #

A WebSocket connection is not a session. When the TCP connection dies, everything the server knew about that client dies with it: the subscription set, the position in the stream, the identity. The new connection is a stranger, and unless you deliberately carry something across the gap, the server has no way to know it is the same user, let alone what they last saw.

That leaves three possible behaviours after a reconnect, and most systems pick the wrong one by default:

Resume nothing. The client reconnects and waits for the next update. Anything published during the gap is lost silently. This is the default when you write no resume logic at all, and it is only acceptable for pure state streams where the next update supersedes everything before it.

Refetch everything. The client requests a full snapshot on every open. Correct, simple, and expensive: a 400 KB snapshot for a two-second gap, multiplied by every client during a rolling deploy, is a self-inflicted traffic spike at the worst possible moment.

Replay the gap. The client remembers the last message it processed, tells the server on reconnect, and the server sends exactly what came after. This needs a per-message sequence, a bounded server-side buffer, and a fallback for when the gap is longer than the buffer.

Resume handshake after a drop The client stores the sequence number of the last message it processed, sends it with its resume token on reconnect, and the server replays only the buffered entries after that point before resuming the live stream. Resume handshake after a drop Client Server Ring buffer msg seq 41, 42, 43 network drops — lastSeq = 43 seq 44, 45 buffered while away reconnect: resume token + lastSeq 43 read entries after 43 replay 44, 45 then live If the requested sequence is older than the buffer, the server sends a snapshot instead and says so
Replay is only correct when the client tells the server where it stopped — a bare reconnect cannot infer it.

Resolution #

Three pieces: a resume token that identifies the session across connections, a bounded per-room ring buffer on the server, and an explicit fallback when the gap exceeds the buffer.

import { WebSocket } from 'ws';

const BUFFER_SIZE = 512; // messages retained per room
const RESUME_WINDOW_MS = 120_000; // how long a session may be resumed after a drop

interface Entry { seq: number; ts: number; payload: unknown; }

/** Bounded ring buffer: memory is O(rooms × BUFFER_SIZE), never O(downtime). */
class RoomLog {
private entries: Entry[] = [];
private nextSeq = 1;

append(payload: unknown): Entry {
const entry = { seq: this.nextSeq++, ts: Date.now(), payload };
this.entries.push(entry);
if (this.entries.length > BUFFER_SIZE) this.entries.shift();
return entry;
}

/** Entries strictly after `afterSeq`, or null when the gap is too old to serve. */
since(afterSeq: number): Entry[] | null {
const oldest = this.entries[0];
if (!oldest) return [];
// The client's position fell out of the window: replay would silently skip
// messages, so refuse and let the caller send a snapshot instead.
if (afterSeq < oldest.seq - 1) return null;
return this.entries.filter((e) => e.seq > afterSeq);
}
}

const rooms = new Map<string, RoomLog>();
const sessions = new Map<string, { room: string; userId: string; expiresAt: number }>();

export function onResume(socket: WebSocket, token: string, lastSeq: number): void {
const session = sessions.get(token);
if (!session || session.expiresAt < Date.now()) {
// Unknown or expired token: this is a new session, not a resume.
socket.send(JSON.stringify({ t: 'resume.rejected', reason: 'expired' }));
return;
}

const log = rooms.get(session.room);
const missed = log ? log.since(lastSeq) : null;

if (missed === null) {
// Gap longer than the buffer — a snapshot is both cheaper and correct.
socket.send(JSON.stringify({ t: 'snapshot.required', room: session.room }));
return;
}

socket.send(JSON.stringify({ t: 'resume.accepted', replaying: missed.length }));
for (const e of missed) socket.send(JSON.stringify({ t: 'msg', seq: e.seq, d: e.payload }));
// Only after the replay drains does this socket join the live fan-out, so the
// client can never interleave a replayed message with a newer live one.
joinLiveFanout(socket, session.room);
}

On the client, persist lastSeq alongside the resume token and update it only after a message has been applied, not when it arrives. The distinction matters: a message that arrived and then threw during reducing must be replayed, and advancing the cursor on receipt loses it permanently.

Because replay is at-least-once by construction — a client can reconnect after processing a message but before persisting the cursor — every handler must be idempotent. That is the same requirement described in idempotent WebSocket message processing, and resume is the most common reason it stops being optional.

Bytes to catch up: replay versus snapshot Replaying a short gap costs a few kilobytes while a snapshot always costs the full room state; beyond about five minutes the replay is no cheaper and the buffer has usually expired anyway. Bytes to catch up: replay versus snapshot 1.6 KB per message at 8 messages/s, against a 400 KB room snapshot Replay KB Snapshot KB 0 KB 100 KB 200 KB 300 KB 400 KB 2s gap 10s gap 95 KB 60s gap 5min gap
The crossover is where the buffer should end: below it replay wins decisively, above it the snapshot is both cheaper and simpler.

The session itself outlives several connections, and drawing that explicitly is what stops people conflating the two.

Session states across a reconnect A session moves from live to orphaned when the socket drops, back through resuming when the client returns with a cursor, or to expired if it stays away longer than the resume window. Session states across a reconnect Live socket open, seq advancing Orphaned socket gone, token valid Resuming replaying the gap Expired token past window drop reconnect + lastSeq replay drained window elapsed Only the expired path costs a full snapshot — everything else replays a handful of messages
The session is the durable thing; the connection is disposable. Resume is what keeps that true.

Verification #

Test the three paths explicitly — short gap, long gap, and expired session — because only the first one ever happens by accident in development.

# Drop a client mid-stream and reconnect with a stale cursor
npx wscat -c ws://localhost:8080/ws -x '{"t":"resume","token":"abc","lastSeq":43}'

# Confirm the buffer is bounded rather than growing with uptime
curl -s localhost:9090/metrics | grep -E 'ws_room_log_entries|ws_resume_(accepted|rejected)_total'

The metric that matters is the ratio of resume.accepted to snapshot.required. In a healthy system the overwhelming majority of reconnects are accepted resumes; a rising snapshot rate means either gaps are getting longer (a network or deploy problem) or your buffer is too small for your message rate. Both are actionable, and neither is visible without the counter.

Operational checklist #

FAQ #

Where should the resume buffer live with multiple nodes? #

In shared storage, not in the process — a reconnecting client will usually land on a different node. Redis Streams is a natural fit because it is already an ordered log with trimming built in, which is exactly the comparison drawn in Redis Streams vs Pub/Sub for WebSocket fan-out. An in-process ring buffer only works if you have sticky routing strong enough to guarantee the same node, which is a fragile thing to depend on during a deploy.

How large should the buffer be? #

Size it in time rather than entries: pick the longest gap you want to serve — typically 30 to 120 seconds — and multiply by your peak per-room message rate. At 8 messages a second and a 60-second target, 512 entries is right. Bigger buffers rarely help, because a client away for more than a couple of minutes is usually better served by a snapshot anyway.

Does the client need to buffer its own outbound messages too? #

Yes, if losing a user action is unacceptable. Anything the user did during the gap must be queued locally and flushed after the resume is accepted — with an idempotency key so a message sent twice is applied once. That outbound half is covered in queueing WebSocket messages while offline.

What if the client’s sequence number is ahead of the server’s? #

Treat it as corruption and fall back to a snapshot. It happens when a room is recreated with a reset sequence, when a client restores stale storage from a previous deployment, or when someone edits local storage by hand. Never trust the client’s number as authoritative — validate it against the buffer’s range and refuse anything outside it.

Does this work with Socket.IO? #

Socket.IO added a connection-state recovery feature that does much of this automatically: it buffers packets per session for a configurable window and replays them on reconnect. It is a reasonable default if you are already on Socket.IO, but the buffer is per-node unless you use an adapter that supports recovery, and the same snapshot fallback question applies once the window is exceeded.

Back to Auto-Reconnection Strategies