Realtime Cache Synchronization
Polling is a confession that you do not know when data changed, so you ask again on a timer and pay for staleness in the gaps. Push transports — WebSocket, Server-Sent Events, and GraphQL subscriptions — invert that relationship: the server tells the client the moment something changes, and the client’s job becomes translating each event into a precise cache write. This guide is the realtime branch of Cache Invalidation & Server Synchronization, and it complements the timer-driven approach documented in Background Refetch Strategies: where that section keeps data fresh by revalidating on an interval, this one keeps it fresh by reacting to events.
The core problem is not opening a socket — that part is easy. The problem is that a stream of events is a stream of small, ordered, potentially-lossy mutations against a cache that was populated by a completely different code path (a useQuery fetch). Getting this right means deciding per-event whether to patch the cache with queryClient.setQueryData or to flush it with invalidateQueries, making those writes idempotent against reordering, and treating every reconnection as a moment where the cache has silently drifted and must be backfilled.
Diagnostic Checklist
You are looking at a realtime synchronization problem — not a general refetch problem — if you observe:
- Data updates correctly on the tab that made a mutation, but a second tab or another user’s session shows stale data until a manual refresh.
- The UI is correct while the WebSocket is connected, then diverges from the server after the laptop sleeps or the network blips, and never recovers on its own.
- A high-frequency event stream (presence, prices, live scores) pins the CPU because every event triggers a full list re-render instead of a single-row update.
- A late-arriving event overwrites a newer value — a user sees a field revert to an older state for a moment, then correct itself on the next event.
setQueryDataappears to do nothing because you called it with aqueryKeythat does not exactly match the key an activeuseQueryis using, so no observer is notified.- After reconnecting, some entities are fresh (they got an event) but lists are stale (their membership changed while you were disconnected and nothing invalidated them).
Prerequisites
Before wiring a push source into the cache, you should be comfortable with:
- Direct cache writes: how
queryClient.setQueryData(key, updater)writes without a network request and notifies observers, versusinvalidateQuerieswhich marks entries stale and refetches. - Query key matching: how React Query v5 matches a partial
queryKeyprefix during invalidation and requires an exact key for a targetedsetQueryData(covered in Tag-Based Invalidation Systems). - Staleness semantics: why
staleTimeis time-based and therefore blind to server-side events, which is exactly the gap push transports close. - Structural sharing: how React Query v5 diffs updater output to preserve references for unchanged branches, keeping selector-subscribed components from re-rendering.
Implementation 1 — Writing WebSocket Events with setQueryData
The first and most valuable pattern is patching the cache directly from a socket message. When the server pushes an entity’s full next state, there is no reason to refetch it — you already hold the authoritative value. Writing it straight into the cache with setQueryData updates every subscribed component in the same tick, at zero network cost. The detailed recipe lives in Updating the Cache From WebSocket Events; here is the architecture-level shape.
Steps:
- Open one WebSocket for the feature root and decode each frame into a typed event.
- Route the event to a handler that reads the entity’s
queryKey. - Call
setQueryDatawith an immutable updater that returns the next entity, guarded by a version check so stale events are dropped. - For events that change list membership, follow the patch with a targeted
invalidateQueries.
import { useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
interface Order {
id: string;
status: 'pending' | 'shipped' | 'delivered';
version: number;
}
type OrderEvent =
| { type: 'order.updated'; order: Order }
| { type: 'order.created'; order: Order };
export function useOrderStream(url: string) {
const queryClient = useQueryClient();
useEffect(() => {
const socket = new WebSocket(url);
socket.onmessage = (frame) => {
const event = JSON.parse(frame.data) as OrderEvent;
if (event.type === 'order.updated') {
queryClient.setQueryData<Order>(['order', event.order.id], (prev) => {
// Version guard: never let a stale event clobber fresher cache data
if (prev && prev.version >= event.order.version) return prev;
return event.order;
});
}
if (event.type === 'order.created') {
// New membership — the detail cache cannot express a list change,
// so mark the list stale and let React Query refetch its ordering
queryClient.invalidateQueries({ queryKey: ['orders', 'list'] });
}
};
return () => socket.close();
}, [url, queryClient]);
}
Cache Behavior Impact: setQueryData writes synchronously into the query cache and immediately notifies every observer whose queryKey equals ['order', id], so mounted components re-render from cache with no fetch and no loading state. Returning prev unchanged when the version guard fails is a true no-op — React Query compares the returned reference and skips notification entirely, so a stale event costs nothing. Because the updater returns a plain object, structuralSharing diffs it against the previous cache value and preserves references for any nested field that did not change, so a selector reading only order.status will not re-render when only version moved.
Configuration Trade-offs:
- Guarding with a
version(orupdatedAt) field makessetQueryDataidempotent and commutative — safe against replays and reordering — at the cost of the server having to emit a monotonic field per entity. setQueryDatadoes not resetstaleTime; the patched entry keeps its existing staleness clock. If you want the patched value to also suppress the next background refetch, pair the write withstaleTimetuning on the underlyinguseQuery.- Routing
order.createdtoinvalidateQueriesrather than pushing into the list preserves server-authoritative ordering, but incurs a refetch. If ordering is purely chronological you cansetQueryDatathe list directly instead and skip the round trip. - Keep
structuralSharingat its defaulttrueso unchanged branches keep their references; disabling it forces every subscribed row to re-render on every event.
Implementation 2 — Server-Sent Events with Targeted Invalidation
Not every event carries a full payload. Many backends emit thin notifications — “invoice 42 changed” — without the new state attached, either to keep the stream cheap or because the authoritative shape depends on server-side joins. SSE is the natural transport for this: it is a one-way, auto-reconnecting HTTP stream, and it pairs cleanly with invalidateQueries as the “something changed, go refetch it” primitive. This is the event-driven cousin of interval revalidation covered under Background Refetch Strategies.
Steps:
- Open an
EventSourceand parse eachmessageinto a change notification. - Map the changed resource to its
queryKey. - Call
invalidateQueries({ queryKey })so React Query refetches only that entry. - Use the built-in
EventSourcereconnect, and on theopenevent after a drop, invalidate the whole feature scope to backfill.
import { useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
interface ChangeNotice {
resource: 'invoice' | 'customer';
id: string;
}
export function useChangeStream(url: string) {
const queryClient = useQueryClient();
useEffect(() => {
const source = new EventSource(url);
let hadError = false;
source.onmessage = (event) => {
const notice = JSON.parse(event.data) as ChangeNotice;
// Thin event: refetch the exact entry rather than guessing its next value
queryClient.invalidateQueries({ queryKey: [notice.resource, notice.id] });
};
source.onerror = () => {
hadError = true; // browser will auto-reconnect; remember we dropped
};
source.onopen = () => {
if (hadError) {
hadError = false;
// Backfill everything this stream feeds — we cannot know what we missed
queryClient.invalidateQueries({ queryKey: ['invoice'] });
queryClient.invalidateQueries({ queryKey: ['customer'] });
}
};
return () => source.close();
}, [url, queryClient]);
}
Cache Behavior Impact: invalidateQueries({ queryKey }) marks every matching entry as stale and triggers a refetch for any that currently have an active observer; inactive entries are simply flagged and refetch lazily when next mounted. The prefix match means ['invoice'] invalidates ['invoice', '42'], ['invoice', '7'], and any deeper key — one call scopes the whole resource. Because invalidation refetches through the normal queryFn, the returned data flows through structuralSharing, so even a full refetch that returns byte-identical data will not re-render subscribers.
Configuration Trade-offs:
- SSE gives you auto-reconnect for free, but the reconnect is invisible to React Query — you must bridge it to
invalidateQueriesinonopen, or the cache stays stale after every blip. - Invalidating a broad prefix (
['invoice']) on reconnect is simple but can stampede your API with refetches; scope it to only the queries with active observers, or debounce the reconnect backfill. - Thin-event invalidation trades a network round trip for correctness — every event costs a refetch. If your event rate is high, prefer fat events plus
setQueryDatafrom Implementation 1 to avoid a refetch per event. invalidateQueriesrespects each query’sstaleTimefor inactive entries only in that they refetch on next mount; active entries always refetch immediately, so a highstaleTimewill not suppress event-driven invalidation.
Implementation 3 — Apollo Subscriptions and Cache Writes
GraphQL subscriptions are the schema-native version of this pattern: a subscription is a long-lived query that streams results, and Apollo Client v3 gives you two ways to fold those results into the InMemoryCache. useSubscription with an onData handler lets you call cache.modify or writeQuery explicitly, while subscribeToMore on an existing query merges streamed events into that query’s field. Because Apollo normalizes by __typename:id, a subscription that returns an entity with a matching identity patches every query referencing it automatically — no manual key matching required.
Steps:
- Attach
subscribeToMoreto the list query you want to keep live. - Provide an
updateQueryreducer that folds each streamed event into the existing field value. - Return a new, immutable field value so Apollo’s cache diffing can broadcast to watchers.
- For entity-scoped updates that Apollo can normalize automatically, prefer
cache.modifyin anonDatahandler over rewriting the whole list.
import { useEffect } from 'react';
import { useQuery, gql, useApolloClient } from '@apollo/client';
const MESSAGES = gql`
query Messages($channelId: ID!) {
messages(channelId: $channelId) { id body author }
}
`;
const MESSAGE_ADDED = gql`
subscription OnMessageAdded($channelId: ID!) {
messageAdded(channelId: $channelId) { id body author }
}
`;
interface Message { id: string; body: string; author: string }
interface MessagesData { messages: Message[] }
export function useLiveMessages(channelId: string) {
const { data, subscribeToMore } = useQuery<MessagesData>(MESSAGES, {
variables: { channelId },
});
useEffect(() => {
const unsubscribe = subscribeToMore({
document: MESSAGE_ADDED,
variables: { channelId },
updateQuery: (prev, { subscriptionData }) => {
const added = subscriptionData.data?.messageAdded;
if (!added) return prev;
// Idempotency guard: Apollo may replay on reconnect
if (prev.messages.some((m) => m.id === added.id)) return prev;
return { messages: [...prev.messages, added] };
},
});
return () => unsubscribe();
}, [channelId, subscribeToMore]);
return data?.messages ?? [];
}
Cache Behavior Impact: subscribeToMore runs updateQuery against the cached result of the MESSAGES query and writes the returned value back through the normalized InMemoryCache. Because the returned messageAdded entity shares a __typename:id, any other active query holding that message is patched in the same write — Apollo’s normalization does the fan-out. Returning prev unchanged when the message already exists prevents a duplicate on subscription replay, and because the value is referentially identical, Apollo broadcasts nothing. This mirrors the React Query setQueryData version guard, expressed through Apollo’s identity layer instead of an explicit version field.
Configuration Trade-offs:
subscribeToMorekeeps list membership live without a refetch, but you own the merge — an unguarded[...prev, added]will duplicate entries on any replay, so thesome(id)idempotency check is mandatory.- Prefer
cache.modifywithreadFieldfor single-entity field updates so Apollo’s normalization fans out the change; reserveupdateQueryfor list-shape changes it cannot infer. - Apollo’s
keyArgson the subscribed field controls which cache slot the merge targets — misconfiguredkeyArgswill merge events from one channel into another’s cache entry. - A GraphQL subscription over
graphql-wsreconnects with a fresh subscription; treat that like the WebSocket reconnect gap and invalidate or refetch the backing query to backfill events dropped during the outage.
Common Pitfalls & Resolutions
| Observable Issue | Root Cause | Diagnostic Resolution |
|---|---|---|
setQueryData from a socket event updates nothing on screen |
The event’s queryKey does not exactly match the key an active useQuery uses — invalidation matches by prefix but setQueryData requires an exact key |
Log queryClient.getQueryCache().findAll() keys and align the event key precisely; for a family of keys, iterate matches or fall back to invalidateQueries |
| A field flickers to an older value then corrects itself | Out-of-order events: a late frame carrying stale state overwrites a fresher setQueryData write |
Add a monotonic version/updatedAt to entities and return prev from the updater when incoming.version <= prev.version |
| Cache is correct live but permanently stale after a network blip | Reconnect gap: patches stopped arriving while disconnected and nothing re-syncs on reconnect because staleTime is time-based, not event-based |
Bridge the transport’s reconnect (onopen/graphql-ws connected) to invalidateQueries for every stream-fed key to backfill |
| High-frequency stream pins the CPU with re-renders | Each event replaces the whole list reference, or structuralSharing is disabled, so every row reconciles |
Patch individual entity keys with setQueryData, keep structuralSharing: true, and subscribe components via narrow selectors |
| Duplicate rows appear after a subscription reconnect | subscribeToMore/updateQuery appends without an existence check and the server replays a backlog |
Guard the merge with an id existence check and return prev unchanged on duplicates so the write is idempotent |
Frequently Asked Questions
Should a push event call setQueryData directly or invalidateQueries?
Use setQueryData when the event payload contains the full, authoritative next state of an entity — it patches the cache with zero network cost and updates the UI in the same tick. Use invalidateQueries when the event is only a signal that something changed but does not carry enough data to reconstruct the cache slot, for example a list whose ordering depends on server-side ranking. A common and robust hybrid is to setQueryData the entity detail and invalidateQueries the lists that contain it, so the row updates instantly while the list’s membership and order stay server-authoritative.
How do I handle events that arrive out of order over a WebSocket?
WebSocket frames are ordered per connection, but application-level events can still race when they originate from different backend services or when a reconnect replays a backlog. Attach a monotonic version or updatedAt field to every entity and make your setQueryData updater a no-op when the incoming version is not newer than the cached one. This makes writes idempotent and commutative, so a late-arriving stale event returns the previous reference and React Query broadcasts nothing.
What happens to cache freshness during a WebSocket reconnection gap?
While the socket is down the cache stops receiving patches, so it silently drifts from the server. The cache cannot detect this on its own because staleTime measures elapsed time, not missed events. On reconnect you must call invalidateQueries for every query that the socket feeds so React Query refetches and backfills whatever changed during the gap. Treat reconnection as a mandatory invalidation trigger, and if the outage was long, prefer invalidating the whole feature scope over trying to reason about individual entities.
Does structuralSharing help when applying high-frequency push updates?
Yes. When setQueryData returns a new object but most nested fields are unchanged, structuralSharing (on by default in React Query v5) diffs the result and reuses prior references for the unchanged branches. Components subscribed via a selector that reads only an unchanged field will not re-render even though the top-level object was replaced. This is what makes a 10-events-per-second stream viable without a render storm — but it only works if your updater returns plain, JSON-serializable objects rather than class instances or mutated references.
Related
- Cache Invalidation & Server Synchronization — the parent section covering how the cache is kept consistent with the server across mutations, refetching, and invalidation topology that this realtime work plugs into.
- Updating the Cache From WebSocket Events — the step-by-step recipe for the
useEffect+setQueryDatapattern, including immutable updaters, out-of-order guards, and reconnect backfill. - Background Refetch Strategies — the timer-driven counterpart to this section, covering
refetchInterval, window-focus revalidation, and how interval polling coexists with event-driven updates. - Tag-Based Invalidation Systems — how to structure query keys and tags so that a single push event can invalidate exactly the right set of dependent queries without over-fetching.
- Optimizing SWR Revalidation Intervals — tuning revalidation cadence for endpoints where a full push transport is overkill but pure staleness-based polling is too coarse.