Handling Out-of-Order WebSocket Messages #
A price cell flickers between two values and settles on the older one. A document shows an edit being undone by an edit that happened before it. A presence list marks a user offline seconds after they came back. Everyone’s first instinct is that the network reordered the messages — and it did not, because a WebSocket runs over TCP and delivers frames in the order they were sent.
The reordering happened inside your application, and there are exactly four places it can happen.
Root cause #
TCP guarantees ordered delivery on a single connection, and RFC 6455 preserves that for frames. So onmessage fires in order, always. What is not guaranteed is that your effects complete in order:
Async handlers. onmessage = async (ev) => { const parsed = await decode(ev.data); apply(parsed); } starts a promise per message and does not await the previous one. Two messages arriving a millisecond apart can have their apply calls resolve in either order, and the faster payload wins.
Multiple connections. Two sockets — a per-component hook, a second tab, a reconnect that overlapped the old socket’s final frames — have no ordering relationship at all. Messages from the old connection can land after messages from the new one.
Replay after reconnect. A resumed session replays buffered messages while live ones are already arriving. Without an explicit gate, a replayed update from ten seconds ago overwrites the live one that arrived first.
Optimistic updates. The local write applies immediately and the server confirmation arrives later. If the confirmation carries an older version than a second local write, naive application rolls the UI backwards.
Resolution #
Two mechanisms, and you almost always want both. A connection-level sequence gate rejects anything older than what you have already processed, and per-key versioning resolves conflicts between updates to the same entity regardless of which connection they arrived on.
interface Envelope<T> {
s: number; // per-connection monotonic sequence
k: string; // entity key this update applies to
v: number; // per-entity version, assigned by the server
ts: number;
d: T;
}
const MAX_GAP_BEFORE_RESYNC = 50;
export class OrderedStream<T> {
private lastSeq = 0;
private versions = new Map<string, number>();
/** Messages that arrived ahead of a gap, held until the gap fills. */
private pending = new Map<number, Envelope<T>>();
constructor(
private apply: (msg: Envelope<T>) => void,
private resync: () => void,
) {}
receive(msg: Envelope<T>): void {
// 1. Duplicate or stale for this connection — replay and reconnect both
// produce these, and applying them is exactly the flicker bug.
if (msg.s <= this.lastSeq) return;
// 2. A gap: hold newer messages briefly rather than applying them out of
// order, because the missing one may still be in flight.
if (msg.s > this.lastSeq + 1) {
this.pending.set(msg.s, msg);
if (msg.s - this.lastSeq > MAX_GAP_BEFORE_RESYNC) {
// Too far ahead to reconcile: a snapshot is cheaper and certainly correct.
this.pending.clear();
this.resync();
}
return;
}
this.applyOrdered(msg);
// 3. Drain anything that was waiting on this message.
let next = this.pending.get(this.lastSeq + 1);
while (next) {
this.pending.delete(next.s);
this.applyOrdered(next);
next = this.pending.get(this.lastSeq + 1);
}
}
private applyOrdered(msg: Envelope<T>): void {
this.lastSeq = msg.s;
// 4. Per-entity version check. This is what protects against messages from a
// DIFFERENT connection (a reconnect, a second tab) that the sequence gate
// cannot see, and it is what makes last-write-wins actually correct.
const seen = this.versions.get(msg.k) ?? -1;
if (msg.v <= seen) return;
this.versions.set(msg.k, msg.v);
this.apply(msg);
}
}
Then keep the handler synchronous up to the point of ordering. If decoding must be asynchronous, order the awaits, not just the applies:
let chain: Promise<void> = Promise.resolve();
socket.onmessage = (ev) => {
// Serialise the async work: each message's processing awaits the previous
// one, so a slow decode delays later messages instead of overtaking them.
chain = chain.then(async () => {
const msg = await decode(ev.data);
stream.receive(msg);
}).catch(() => { /* one bad message must not break the chain */ });
};
The version number must come from the server, not from a client clock. Two clients with clocks a few hundred milliseconds apart will disagree about which write is newer, and the resulting state depends on whose laptop is fast — which is the same reason optimistic UI rollback on WebSocket NACK reconciles against server versions rather than timestamps.
Verification #
Reproduce reordering deliberately, because it will not happen reliably on a fast local network.
// In a test, deliver messages in a hostile order and assert the state is correct.
stream.receive({ s: 1, k: 'px:AAPL', v: 1, ts: 0, d: 100 });
stream.receive({ s: 3, k: 'px:AAPL', v: 3, ts: 0, d: 102 }); // gap: held
expect(state['px:AAPL']).toBe(100);
stream.receive({ s: 2, k: 'px:AAPL', v: 2, ts: 0, d: 101 }); // fills, then drains
expect(state['px:AAPL']).toBe(102);
stream.receive({ s: 4, k: 'px:AAPL', v: 2, ts: 0, d: 101 }); // stale version
expect(state['px:AAPL']).toBe(102);
In the browser, throttle the network to “Slow 3G” in DevTools and interact rapidly — reordering bugs that never appear on a fast connection surface within seconds. And instrument it: count stream_stale_dropped_total and stream_gap_resync_total in the client and report them with your existing telemetry. A non-zero stale count is healthy (the gate is working); a rising resync count means gaps are common, which points at the connection rather than the ordering logic.
Operational checklist #
FAQ #
Does TCP not guarantee message order already? #
It guarantees byte order on one connection, and WebSocket framing preserves message order on top of that. What it cannot guarantee is that your application processes them in order — the moment you await inside the handler, use more than one connection, or replay a buffer, ordering is your responsibility again. The protocol’s guarantee ends at onmessage.
Do I need sequence numbers if I already have per-entity versions? #
You can get correct final state with versions alone, but you lose two things: the ability to detect that a message was missed at all, and the ability to hold a newer update briefly while a gap fills. Without the sequence, a lost message is invisible until a user notices stale data. With it, the client can resynchronise proactively — which is the same signal resuming WebSocket sessions after reconnect uses to decide between replay and snapshot.
How long should I buffer a message that arrived early? #
Bound it by count rather than time — fifty messages is a reasonable ceiling for most streams. A gap that does not fill within that window is not a delay, it is a loss, and waiting longer only extends the period during which the user sees stale data. Resynchronise instead.
What about messages for different entities that genuinely are independent? #
They do not conflict, so per-key versioning already handles them correctly: a version check on px:AAPL says nothing about px:MSFT. The sequence gate is stricter than necessary for those, which is why the gap-buffering behaviour matters — it holds unrelated newer messages for a moment rather than dropping them, so independent entities are not penalised for another key’s gap.
Does last-write-wins lose data? #
Yes, deliberately, and that is correct for state — a price, a cursor position, a presence flag — where only the current value has meaning. It is wrong for events, where every occurrence matters. Classify each message type before choosing: events need an append-only path with deduplication by id, which is the distinction drawn in message delivery guarantees.
Related #
- State Sync & Optimistic Updates — the reconciliation model these ordering rules serve.
- Optimistic UI Rollback on WebSocket NACK — resolving a local write against a server version.
- Message Framing & Serialization — the envelope that carries the sequence and version fields.
- Resuming WebSocket Sessions After Reconnect — where replayed messages come from and why the gate matters.
- Idempotent WebSocket Message Processing — the server-side counterpart for duplicate delivery.
Back to State Sync & Optimistic Updates