Resume a WebSocket stream after reconnect without losing messages
On reconnect, subscribe with buffering first, then replay the gap or send a snapshot, then drain the buffer. The reverse order loses messages.
The client reconnects with its epoch and last applied sequence per stream. The server must subscribe the connection to live messages, with buffering, before it reads anything else. Then it replays the gap or sends a snapshot, and only then flushes the buffer. Messages committed during the reads arrive twice at worst, and the client already drops duplicates.
async function resume(conn: Connection, stream: string, epoch: string, lastSeq: number) {
const live = conn.subscribe(stream, { buffer: true }); // 1. capture live messages first
const head = await log.head(stream); // { epoch, seq }
const oldest = await log.oldestSeq(stream);
const replayable = epoch === head.epoch && lastSeq <= head.seq && lastSeq + 1 >= oldest;
if (replayable) {
for (const m of await log.range(stream, lastSeq + 1, head.seq)) conn.send(m); // 2a. replay the gap
} else {
const snap = await snapshots.read(stream); // 2b. state + the epoch and seq it reflects
conn.send({ kind: "snapshot", stream, epoch: snap.epoch, seq: snap.seq, data: snap.state });
}
live.flush(); // 3. drain; the client drops anything at or below what it already applied
}Why does it work?
- Subscribing first turns a window of lost messages into a window of duplicates. The tempting order, snapshot and then subscribe, loses every message committed between those two steps. If the stream then goes quiet nothing reveals the loss, because the client checks continuity only when the next message arrives.
- Replay is chosen only when it is provably complete: same epoch, the client is not ahead of the head, and the first missing message (
lastSeq + 1) is still retained. In every other case the snapshot path runs. - The snapshot must be consistent with its sequence number. Read the state and the stream's head in one
REPEATABLE READtransaction (or a single statement); under the defaultREAD COMMITTED, two statements can see different commits.
When should you not use it?
- For "latest value wins" streams, skip the log: send the current value on reconnect.
- For concurrent edits to shared state, use a CRDT or OT scheme with its own versioning.
- Alone it does not survive a reconnect storm after a deploy or a load balancer restart. Add exponential backoff with jitter on the client and cap concurrent snapshot reads on the server.