Real-time· 6 min read
WebSockets and React Query can disagree — pick one source of truth
Real-time events writing directly into client state is a desync factory. Funneling socket events through cache invalidation fixed an entire bug class.
The bug reports all looked different. A visa application showing stage three on one screen and stage four on another. A chat thread with a message that vanished after refresh. A pipeline board that disagreed with the detail view it linked to. Different screens, different features — same root cause. We had two systems that both believed they owned client state: React Query, hydrating from REST, and a WebSocket layer, pushing live updates straight into components.
Every screen that subscribed to both was a race condition with a UI attached.
How the desync actually happens
The seductive pattern is the direct write: a socket event arrives — 'application moved to stage four' — and the handler writes it into local state or patches the query cache by hand. It demos beautifully. The update is instant.
Then reality arrives in three flavors. Ordering: a REST refetch that started before the socket event resolves after it, and overwrites newer data with older. Coverage: the socket event patches the detail view's cache entry but misses the three list views that also render that application. Reconnection: the socket drops for forty seconds on hotel Wi-Fi, and every update sent in that window simply never happened as far as the client knows.
Each of those produced bugs we could reproduce only sometimes, on some screens — the most expensive kind to fix one at a time.
Demote the socket from writer to messenger
The fix was a demotion. The server stopped being a second writer of client state and became a notifier: socket events stopped carrying authoritative payloads and started carrying hints — 'something about application 4123 changed'. The handler does exactly one thing: invalidate the relevant queries. React Query refetches through the same REST path everything else uses, and the cache — the single source of truth — updates once, consistently, for every subscribed view.
Ordering bugs disappear because the refetch always returns current truth, not an event snapshot. Coverage bugs disappear because invalidation fans out to every query that touches the entity, list views included. Reconnection collapses to one rule: on reconnect, invalidate what you were watching. Missed events don't need replaying — the next fetch is the replay.
socket.on("entity:changed", ({ type, id }) => {
queryClient.invalidateQueries({ queryKey: [type, id] });
queryClient.invalidateQueries({ queryKey: [type, "list"] });
});
socket.on("reconnect", () => {
queryClient.invalidateQueries({ predicate: isLiveQuery });
});The trade-off, priced honestly
You pay one extra round-trip per update — the notification, then the refetch. For our pipeline dashboards, an update landing 200ms later was invisible; an update that was wrong was a support ticket. We kept direct socket payloads in exactly one place: chat, where the message object is immutable, append-only, and rendered in a single component. That's the honest boundary — direct writes are fine when the data can't be stale-overwritten and has one consumer. Everywhere data is shared, mutable, and multi-view, the invalidation path won.
The deeper lesson generalizes past WebSockets: every piece of client state needs exactly one writer. The moment two systems can both say what's true, the question isn't whether they'll disagree — it's which screen your users will notice it on first.
What to take away
- Socket events are notifications, not state. Let them trigger invalidation; let one fetch path own truth.
- Direct cache writes from sockets create ordering, coverage, and reconnection bugs that appear intermittently across unrelated screens.
- Reconnection logic collapses to 'invalidate watched queries' — no event replay needed.
- Reserve direct payload writes for immutable, single-consumer data like chat messages.
- One writer per piece of state — that rule outlives any particular library.