Handling Out-of-Order Realtime Updates
A socket appears to give you ordering for free, and within a single connection it does. What it does not give you is ordering across a reconnect, ordering relative to the HTTP refetches your cache is doing in parallel, or any guarantee that the order the server sent events matches the order it committed them. The result is a cache that occasionally settles on a value the server overwrote a second earlier, with no error anywhere. This page covers the guards that fix it, under Realtime Cache Synchronization. It builds directly on Updating Cache From WebSocket Events, and it shares its version-guard machinery with Concurrency & Race Conditions.
Diagnostic Checklist
Prerequisites
- A socket or SSE stream that carries entity payloads, not just notifications.
- Server-side ability to attach a monotonic version to each event.
- The cache write path from Updating Cache From WebSocket Events.
Step 1 — Stamp Every Frame Server-Side
Ordering must come from where the change was committed. A client timestamp records arrival, which is exactly the thing that is wrong.
export interface EntityFrame<T> {
entity: 'todo' | 'board' | 'comment';
id: string;
/** Monotonic per entity, assigned when the server commits the write. */
version: number;
/** Monotonic across the whole stream, used for gap detection. */
seq: number;
op: 'upsert' | 'delete';
data: T | null;
}
export function isFrame(value: unknown): value is EntityFrame<unknown> {
return (
typeof value === 'object' && value !== null &&
typeof (value as EntityFrame<unknown>).id === 'string' &&
Number.isInteger((value as EntityFrame<unknown>).version) &&
Number.isInteger((value as EntityFrame<unknown>).seq)
);
}
Cache Behavior Analysis: Two counters do two different jobs. The per-entity version decides whether a given frame is newer than what the cache holds for that record, which is the comparison that fixes reordering. The stream-wide seq lets the client notice that it never saw frame 4,102 at all — something no per-entity counter can detect, because the missing frame may have been for an entity the client has never cached. Validating the frame shape before touching the cache matters more on a socket than on HTTP: there is no status code, so a malformed payload from a partially deployed server otherwise reaches setQueryData and writes garbage.
Step 2 — Reject Frames Older Than the Cache
The guard lives at the write boundary and compares versions before the update function runs.
import type { QueryClient } from '@tanstack/react-query';
interface Versioned { version: number }
export function applyFrame<T extends Versioned>(client: QueryClient, frame: EntityFrame<T>): boolean {
const key = [frame.entity, 'detail', frame.id] as const;
if (frame.op === 'delete') {
const current = client.getQueryData<T>(key);
// A delete stamped older than the cached record is a replayed frame.
if (current && current.version > frame.version) return false;
client.removeQueries({ queryKey: key, exact: true });
return true;
}
let applied = false;
client.setQueryData<T>(key, (current) => {
// Strictly greater: an equal version is a duplicate delivery, not news.
if (current && current.version >= frame.version) return current;
applied = true;
return frame.data ?? current;
});
// Lists that contain this entity need to know it changed, but a frame is not
// authoritative about list membership — mark stale and let the server decide.
if (applied) {
client.invalidateQueries({ queryKey: [frame.entity, 'list'], refetchType: 'active' });
}
return applied;
}
Cache Behavior Analysis: Returning current unchanged from the setQueryData updater is important beyond correctness — TanStack Query compares the returned value by reference, so returning the same object means no observers are notified and no re-render occurs, making a rejected frame genuinely free. Using >= rather than > makes duplicate delivery a no-op, which matters because at-least-once delivery is the norm for socket infrastructure behind a load balancer. Invalidating the list rather than splicing the entity into it is deliberate: a frame knows the entity changed but not whether it still matches the list’s filters, and guessing produces rows that appear in lists they do not belong to.
Step 3 — Detect Gaps and Repair Them
A rejected frame is fine. A frame that never arrived is not, because the cache silently stops converging.
export function createGapDetector(client: QueryClient, onGap: (from: number, to: number) => void) {
let lastSeq: number | null = null;
return {
observe(frame: EntityFrame<unknown>) {
if (lastSeq !== null && frame.seq > lastSeq + 1) {
// We missed everything strictly between lastSeq and frame.seq.
onGap(lastSeq + 1, frame.seq - 1);
}
// Never move backwards: a late frame must not reset the high-water mark.
if (lastSeq === null || frame.seq > lastSeq) lastSeq = frame.seq;
},
get highWater() {
return lastSeq;
},
};
}
// Repair is a refetch, not a request for the missing frames: the current state
// is what matters, and it is strictly more useful than the events that produced it.
const detector = createGapDetector(queryClient, () => {
void queryClient.invalidateQueries({ queryKey: ['todos'], refetchType: 'active' });
void queryClient.invalidateQueries({ queryKey: ['boards'], refetchType: 'active' });
});
Cache Behavior Analysis: Repairing with an invalidation rather than a replay request is both simpler and more correct: the current server state already reflects every missed event, so one refetch subsumes an arbitrary number of gaps, and it cannot itself arrive out of order because the response carries fresh versions. Restricting refetchType to 'active' means only entries a component is actually rendering are refetched immediately; everything else is marked stale and repaired when it is next read. Keeping lastSeq monotonic is what stops a late frame from manufacturing a phantom gap and triggering a refetch storm on a flaky connection.
Step 4 — Reconcile After a Reconnect
A reconnect is a gap of unknown size. Treat it as one, rather than trusting whatever the server chooses to replay.
export function attachSocket(client: QueryClient, url: string) {
let socket: WebSocket | null = null;
let backoff = 500;
const connect = () => {
socket = new WebSocket(url);
socket.onopen = () => {
backoff = 500;
// Everything cached may have changed while we were away. Mark it all
// stale; active screens repair immediately, the rest on next read.
client.invalidateQueries({ refetchType: 'active' });
};
socket.onmessage = (event) => {
const parsed: unknown = JSON.parse(event.data as string);
if (!isFrame(parsed)) return;
detector.observe(parsed);
applyFrame(client, parsed as EntityFrame<Versioned>);
};
socket.onclose = () => {
socket = null;
// Exponential backoff with a ceiling; a reconnect storm helps nobody.
backoff = Math.min(backoff * 2, 15_000);
setTimeout(connect, backoff);
};
};
connect();
return () => socket?.close();
}
Cache Behavior Analysis: Invalidating on open rather than on close is the ordering that matters: at close you have no connection to repair with, and marking everything stale then would trigger refetches while the network is still down, which pile up as retries. Because applyFrame guards on version, replayed frames delivered after reconnect are harmless even though the invalidation has already refetched fresher data — the refetch’s version is higher, so the replay is rejected. The backoff ceiling protects the server during an outage, when every connected client is trying to reconnect simultaneously and an unbounded retry loop turns a brief incident into a sustained one.
Edge Cases & Gotchas
Versions that reset. A server that restarts and re-issues versions from zero makes every subsequent frame look stale, so the cache freezes. Include an epoch alongside the version and treat an epoch change as a full reconciliation.
Optimistic updates and incoming frames. A frame for an entity with a pending optimistic mutation can overwrite the optimistic value, producing a visible flicker before the mutation settles. Skip frames for entities with an in-flight mutation on the same key and let onSettled reconcile.
Lists ordered by a mutable field. Invalidating the list on every frame is correct but expensive on a high-frequency stream. Debounce list invalidation to a short window so a burst of twenty entity updates produces one list refetch.
Common Pitfalls & Resolutions
| Observable Issue | Root Cause | Diagnostic Resolution |
|---|---|---|
| A field reverts to an older value momentarily | A late frame overwrote a newer one | Compare version inside setQueryData and return current when not newer |
| A counter increments twice for one event | The frame was delivered twice | Use >= in the guard so an equal version is a no-op |
| The cache stops updating after a flaky period | A frame was missed and nothing noticed | Add stream-sequence gap detection and repair by invalidation |
| A reconnect storm follows an outage | Reconnect has no backoff ceiling | Cap exponential backoff and jitter it |
| An optimistic edit flickers when a frame lands | A frame was applied while a mutation was pending | Skip frames for entities with an in-flight mutation |
Frequently Asked Questions
Does a WebSocket not guarantee message order?
It guarantees order within a single connection — frames are delivered in the order they were written to that socket. That is a narrower promise than it sounds. It says nothing about the order in which the server produced the events, which may involve several workers writing to a shared bus; nothing about ordering across a reconnect, where a new connection may replay a buffer; and nothing about ordering relative to the HTTP refetches your cache performs in parallel, which is a completely separate channel. In practice, a client that both polls and listens has two unsynchronised sources of truth, and only a server-assigned version can order them.
Should a client-side timestamp be used for ordering?
No. Arrival time at the client is precisely the quantity that is wrong — the whole problem is that a frame committed earlier arrived later. Ordering has to come from a value the server assigns at commit time, so that comparing two frames answers “which change happened first” rather than “which packet won the race”. This is also why Date.now() on the server is a poor substitute for a counter: clock skew between server instances reintroduces the same ambiguity you were trying to remove.
Is it better to refetch than to apply socket payloads?
For low-frequency events, almost always. Treating a frame as “something changed, go and look” removes every ordering concern in this page: the refetch response is authoritative, carries its own fresh version, and cannot be stale relative to itself. The cost is one request per event, which is fine at a few events a minute and untenable at a few a second. Applying payloads directly is worth the version-guard machinery only when the event rate makes refetching impractical — and even then, a hybrid works well: apply payloads for the entity the user is looking at, and invalidate for everything else.
Related
- Realtime Cache Synchronization — the parent guide on keeping a cache in step with a live stream.
- Updating Cache From WebSocket Events — the write path these guards wrap.
- Concurrency & Race Conditions — the same version-guard idea applied to mutations and queries.
- Resolving Out-of-Order Responses With Request IDs — sequence stamping for hand-rolled fetch paths.