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.


Push event to cache write decision flow A WebSocket, SSE, or subscription push source feeds an event router. Full-payload events branch to setQueryData which patches a single entity slot. Signal-only events branch to invalidateQueries which refetches affected lists. A reconnect path triggers a bulk invalidation that backfills missed events into the cache. Push Source WebSocket SSE stream GraphQL sub emits events Event Router decode + guard version check route by type setQueryData full payload → patch entity immutable updater, no fetch ['entity', id] invalidateQueries signal only → refetch list targeted queryKey match ['list', filter] Reconnect gap detected invalidate all stream-fed queries → backfill missed events the cache cannot know it drifted while disconnected TRANSPORT DISPATCH CACHE WRITE STRATEGY
Every push event routes to one of two cache strategies; reconnection forces a backfill because time-based staleness cannot detect an event gap.

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.
  • setQueryData appears to do nothing because you called it with a queryKey that does not exactly match the key an active useQuery is 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, versus invalidateQueries which marks entries stale and refetches.
  • Query key matching: how React Query v5 matches a partial queryKey prefix during invalidation and requires an exact key for a targeted setQueryData (covered in Tag-Based Invalidation Systems).
  • Staleness semantics: why staleTime is 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:

  1. Open one WebSocket for the feature root and decode each frame into a typed event.
  2. Route the event to a handler that reads the entity’s queryKey.
  3. Call setQueryData with an immutable updater that returns the next entity, guarded by a version check so stale events are dropped.
  4. 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 (or updatedAt) field makes setQueryData idempotent and commutative — safe against replays and reordering — at the cost of the server having to emit a monotonic field per entity.
  • setQueryData does not reset staleTime; the patched entry keeps its existing staleness clock. If you want the patched value to also suppress the next background refetch, pair the write with staleTime tuning on the underlying useQuery.
  • Routing order.created to invalidateQueries rather than pushing into the list preserves server-authoritative ordering, but incurs a refetch. If ordering is purely chronological you can setQueryData the list directly instead and skip the round trip.
  • Keep structuralSharing at its default true so 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:

  1. Open an EventSource and parse each message into a change notification.
  2. Map the changed resource to its queryKey.
  3. Call invalidateQueries({ queryKey }) so React Query refetches only that entry.
  4. Use the built-in EventSource reconnect, and on the open event 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 invalidateQueries in onopen, 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 setQueryData from Implementation 1 to avoid a refetch per event.
  • invalidateQueries respects each query’s staleTime for inactive entries only in that they refetch on next mount; active entries always refetch immediately, so a high staleTime will 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:

  1. Attach subscribeToMore to the list query you want to keep live.
  2. Provide an updateQuery reducer that folds each streamed event into the existing field value.
  3. Return a new, immutable field value so Apollo’s cache diffing can broadcast to watchers.
  4. For entity-scoped updates that Apollo can normalize automatically, prefer cache.modify in an onData handler 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:

  • subscribeToMore keeps list membership live without a refetch, but you own the merge — an unguarded [...prev, added] will duplicate entries on any replay, so the some(id) idempotency check is mandatory.
  • Prefer cache.modify with readField for single-entity field updates so Apollo’s normalization fans out the change; reserve updateQuery for list-shape changes it cannot infer.
  • Apollo’s keyArgs on the subscribed field controls which cache slot the merge targets — misconfigured keyArgs will merge events from one channel into another’s cache entry.
  • A GraphQL subscription over graphql-ws reconnects 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.


  • 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 + setQueryData pattern, 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.