Updating TanStack Query cache from WebSockets #

Your app fetches data with TanStack Query (React Query) and receives live updates over a WebSocket. The two fight. A WebSocket event updates a local useState copy of a list, then the query refetches on window focus and overwrites it with a response that was generated before the event. Or the socket handler calls invalidateQueries on every message, and a busy channel turns into a refetch storm that hammers your API. Both come from treating the socket and the query cache as two sources of truth. The robust pattern keeps the query cache as the only store: HTTP fills it, and WebSocket events either patch it in place or mark it stale, depending on what the event carries.

Root cause #

TanStack Query manages server state with a specific model: each query key maps to cached data plus metadata (when it was fetched, whether it is stale), and the library decides when to refetch. A WebSocket handler that keeps its own copy of the same entities creates a second cache with no shared notion of freshness. Whichever writes last wins, and “last” is determined by network timing, not by which data is newer.

Invalidation on every event has the opposite problem. invalidateQueries marks matching queries stale and refetches the ones currently rendered. That is correct for rare, coarse events (“the project settings changed”) and disastrous for frequent, fine-grained ones (“a card moved”), because each event costs a full HTTP round trip and the refetched response may already be outdated by the next event.

A refetch overwriting a newer event A refetch begins, a WebSocket event applies version 8 to the cache, then the HTTP response generated at version 7 arrives and overwrites the newer data. A refetch overwriting a newer event Query cache HTTP API WebSocket refetch starts (focus) card.moved v8 applied response built at v7 cache now shows v7 Without versions, the cache cannot tell that the response is older than the event
Last write wins — and the last write is not always the newest data.

Resolution #

Route every WebSocket event through one handler that decides, per event type, between two actions. Patch when the event carries the full new state of an entity: write it into every cached query that contains the entity with setQueryData, but only if its version is newer. Invalidate when the event only says something changed without carrying the data, or when the change affects queries you cannot patch reliably (aggregates, server-side sorting, pagination). Cancel in-flight fetches before patching so an older response cannot land on top.

import { QueryClient, useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';

interface Card { id: string; listId: string; title: string; position: number; version: number }
type BoardEvent =
| { type: 'card.updated'; card: Card } // full entity: patch
| { type: 'card.deleted'; id: string; listId: string; version: number }
| { type: 'board.reordered'; boardId: string }; // coarse: invalidate

const keys = {
card: (id: string) => ['card', id] as const,
list: (listId: string) => ['list', listId, 'cards'] as const,
board: (boardId: string) => ['board', boardId] as const,
};

function newer(incoming: { version: number }, current?: { version: number }) {
return !current || incoming.version > current.version; // ignore stale or duplicate events
}

export async function applyBoardEvent(qc: QueryClient, ev: BoardEvent) {
switch (ev.type) {
case 'card.updated': {
// Stop any in-flight fetch of these keys so an older response can't overwrite the patch.
await qc.cancelQueries({ queryKey: keys.card(ev.card.id) });
await qc.cancelQueries({ queryKey: keys.list(ev.card.listId) });
qc.setQueryData<Card>(keys.card(ev.card.id), (old) => (newer(ev.card, old) ? ev.card : old));
qc.setQueryData<Card[]>(keys.list(ev.card.listId), (old) => {
if (!old) return old; // not cached: nothing to patch
const i = old.findIndex((c) => c.id === ev.card.id);
if (i >= 0 && !newer(ev.card, old[i])) return old;
const next = i >= 0 ? old.map((c) => (c.id === ev.card.id ? ev.card : c)) : [...old, ev.card];
return next.sort((a, b) => a.position - b.position);
});
return;
}
case 'card.deleted': {
qc.removeQueries({ queryKey: keys.card(ev.id) });
qc.setQueryData<Card[]>(keys.list(ev.listId), (old) => old?.filter((c) => c.id !== ev.id));
return;
}
case 'board.reordered':
// Too structural to patch safely: mark stale; active queries refetch once.
await qc.invalidateQueries({ queryKey: keys.board(ev.boardId) });
return;
}
}

export function useBoardEvents(socket: WebSocket | null) {
const qc = useQueryClient();
useEffect(() => {
if (!socket) return;
const onMessage = (e: MessageEvent) => void applyBoardEvent(qc, JSON.parse(e.data));
// After a reconnect, events were missed: refetch everything live data touches.
const onOpen = () => void qc.invalidateQueries({ queryKey: ['list'] });
socket.addEventListener('message', onMessage);
socket.addEventListener('open', onOpen);
return () => {
socket.removeEventListener('message', onMessage);
socket.removeEventListener('open', onOpen);
};
}, [socket, qc]);
}

With patches keeping the cache current, you can relax TanStack Query’s own freshness machinery for these keys: set a long staleTime (minutes, or Infinity) so focus and mount do not trigger redundant refetches, and rely on the reconnect invalidation to cover gaps. The version check is what makes patching safe against reordering; it is the same guard described in handling out-of-order WebSocket messages.

For events that arrive in bursts, batch invalidations: collect the keys touched in one animation frame and call invalidateQueries once per key, rather than once per event.

Patch or invalidate? Whether to patch the cache or invalidate queries for entity updates, deletions, change notifications, aggregate changes and reconnects, with the network cost of each. Patch or invalidate? Event carries Action Cost Entity updated full entity + version setQueryData no request Entity deleted id + version remove + filter no request Something changed only a key invalidateQueries 1 refetch per active query Aggregate affected counts, sort order invalidateQueries 1 refetch After reconnect unknown gap invalidate live keys burst of refetches Patch whenever the event is self-sufficient; invalidate when it is not
Rich events avoid requests; thin events trade a refetch for simplicity.

Edge cases #

Entities in many queries. A card may appear in its list, a search result, an “assigned to me” view and its detail query. Patching only the obvious keys leaves the others stale. Either iterate cached queries with qc.getQueriesData({ queryKey: ['search'] }) and patch each, or invalidate the query families you cannot patch.

Infinite queries. Paginated data is stored as { pages, pageParams }. Patch by mapping over pages; inserting a new item correctly into the right page is often not worth it — invalidate the infinite query instead and let it refetch the pages in use.

Optimistic mutations. If useMutation applied an optimistic update, the WebSocket echo of the same change should not double-apply it. Versions solve this too: the echo carries the version the server assigned, which the optimistic entry lacks, so the echo replaces it. See optimistic UI rollback on WebSocket nack.

Verification #

Open the TanStack Query Devtools alongside the Network panel. Trigger an update from another browser: the affected query’s data should change in the devtools with no new HTTP request for patched event types, and with exactly one refetch for invalidated ones. Then disconnect the network for a few seconds, make changes elsewhere, and reconnect: the list queries should refetch once and converge.

Write a unit test for the stale-overwrite race: start a fetch that resolves with version 7, apply a card.updated event with version 8 before it resolves, and assert the cache ends at version 8.

Cache freshness with patches and a reconnect After an initial fetch, two WebSocket patches update the cache without requests; the socket drops at thirty-four seconds, and on reconnect an invalidation triggers one refetch that brings the cache to version five. Cache freshness with patches and a reconnect events missed initial fetch (0 s) patch v2 (no request) (12 s) patch v3 (no request) (25 s) socket drops (34 s) reconnect: invalidate (41 s) one refetch, v5 (42 s) Patches keep data live; the reconnect refetch closes the gap patches cannot see
Two mechanisms, one cache: patch while connected, refetch after a gap.

Operational checklist #

FAQ #

Should I call invalidateQueries on every WebSocket message? #

Only if messages are rare. Each invalidation refetches every active matching query, so frequent events become a stream of HTTP requests. Patch with setQueryData when the event carries the data.

Does setQueryData trigger a refetch? #

No. It writes data into the cache and notifies subscribers, which re-render. It also marks the data as freshly updated, so it will not be considered stale until staleTime passes.

How do I handle an event for a query that isn’t cached yet? #

Skip it. The updater function receives undefined, and returning undefined leaves the cache empty; when the query mounts, it fetches current data from the server, which already includes the change.

Can the WebSocket replace HTTP fetching entirely? #

It can, by delivering a snapshot on subscribe — but then you are rebuilding TanStack Query’s caching, deduplication and retry on top of the socket. Keeping HTTP for initial loads and the socket for changes uses each for what it is good at.

Back to React WebSocket Custom Hooks.