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.
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.
The session itself outlives several connections, and drawing that explicitly is what stops people conflating the two.
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.
Related #
- Auto-Reconnection Strategies — getting the connection back before you can resume anything on it.
- Exponential Backoff with Jitter for WebSocket Reconnects — the retry schedule that decides how long a gap is.
- Message Delivery Guarantees — the at-least-once semantics replay depends on.
- Handling Out-of-Order WebSocket Messages — the client-side sequence gate that makes replay safe to apply.
- WebSocket Close Codes Explained — telling a resumable drop from a terminal one.
Back to Auto-Reconnection Strategies