Mustafa Nevfel

Snippet · TypeScript

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.

ts
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.seq makes 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.
  • resync must 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 + 1 would trigger needless resyncs. Check msg.prev === current.seq instead.