Handle duplicate and missing WebSocket messages on the client
Drop sequence numbers at or below the last applied, resync on a gap or epoch change, and let snapshots replace state instead of applying to it.
Keep the epoch and last applied sequence per stream and pass every message through one function. A different epoch or a gap triggers a resync and leaves state untouched, a sequence at or below the last applied is dropped as a duplicate, and only last + 1 is applied. A snapshot replaces state instead.
type Envelope<T> = { stream: string; epoch: string; seq: number; data: T };
type Applied<S> = { epoch: string; seq: number; state: S };
function receive<S, T>(
current: Applied<S>,
msg: Envelope<T>,
apply: (state: S, data: T) => S,
resync: () => void,
): Applied<S> {
if (msg.epoch !== current.epoch) {
resync(); // sequence space was reset: start from a snapshot
return current;
}
if (msg.seq <= current.seq) return current; // duplicate from replay: ignore
if (msg.seq > current.seq + 1) {
resync(); // gap: never apply out of order
return current;
}
return { epoch: current.epoch, seq: msg.seq, state: apply(current.state, msg.data) };
}
function receiveSnapshot<S>(
current: Applied<S>,
snap: { epoch: string; seq: number; data: S },
): Applied<S> {
if (snap.epoch === current.epoch && snap.seq < current.seq) return current; // stale response
return { epoch: snap.epoch, seq: snap.seq, state: snap.data };
}Why does it work?
- Duplicates are expected. After a reconnect the replay and the live buffer overlap by design, so dropping anything at or below
current.seqmakes the overlap harmless. - A gap is never patched over. Applying message 4815 on top of 4812 produces a state that never existed on the server, so the client keeps its state and resyncs.
- The epoch check comes first because sequence numbers from different epochs are not comparable. Restores, migrations and in-memory broker restarts reset the sequence space; comparing across the reset would drop valid messages as duplicates.
resyncmust be idempotent, with one request in flight per stream: every message that arrives before the snapshot lands takes the same branch.- The snapshot carries its own epoch and sequence. The stale check rejects a response that arrives after newer messages were applied.
When should you not use it?
- For "latest value wins" streams (position, gauges, presence), keep the newest value and ignore older ones; gap detection and resync add nothing.
- When the sequence comes from a log shared by many streams (Kafka offsets, JetStream sequences), holes are normal and
seq + 1would trigger needless resyncs. Checkmsg.prev === current.seqinstead.