Syncing Query Cache Across Tabs With BroadcastChannel

Two tabs of the same application share nothing. Edit a record in one and the other keeps rendering the old value until something happens to refetch it, which on a well-tuned cache may be several minutes. BroadcastChannel closes that gap with a few lines — and, done naively, replaces a staleness bug with a refetch storm that scales with how many tabs the user has open. This page is the implementation that avoids both, under Cross-Tab Cache Synchronization. It pairs with Electing a Leader Tab for Refetching, which handles the polling half, and Handling Auth Logout Across Tabs, which is the one message that must never be deferred.

Prerequisites

  • Query keys built by a factory, so a prefix means the same thing in every tab.
  • Keys that are JSON-serializable — see Serializing Dates and Sets in Query Keys.
  • A place to open the channel once, at client construction.
What crosses the channel, and what each tab does with it Tab A completes a mutation and publishes a key prefix with its own origin id. Tabs B and C receive it, skip messages from themselves, and mark the matching entries stale without fetching. When tab B is later focused, its stale entries refetch. The message is four bytes of intent, not a payload Tab A — mutation done publish ['todos','list'] plus its own origin id channel cache-sync/v1 structured clone, same origin Tab B — hidden marks stale, does not fetch Tab C — hidden marks stale, does not fetch the storm: refetchType defaults to 'active' hidden tabs still have mounted observers, so all of them fetch at once Tab B is focused two minutes later — refetchOnWindowFocus fires exactly then One request, at the moment someone is actually looking at the data.
Marking stale rather than refetching is what makes the cost independent of how many tabs are open.

Step 1 — Open One Versioned Channel

The channel name is part of your wire format. Versioning it means a deployed change to key structure cannot be misread by an old tab.

import type { QueryClient } from '@tanstack/react-query';

const CHANNEL_NAME = 'cache-sync/v1';
const TAB_ID = crypto.randomUUID();

export type SyncMessage =
  | { kind: 'invalidate'; prefix: unknown[]; origin: string }
  | { kind: 'signout'; origin: string };

export function openSyncChannel() {
  // One channel per document. Opening a second in the same tab means the tab
  // receives its own messages, which is what the origin guard is for.
  const channel = new BroadcastChannel(CHANNEL_NAME);
  return {
    channel,
    tabId: TAB_ID,
    close: () => channel.close(),
  };
}

Cache Behavior Analysis: BroadcastChannel is scoped to the origin, so every tab, iframe and worker on the same origin that opens this name joins the same bus — including a service worker, which is occasionally useful and occasionally surprising. Putting a version in the name means a deploy that changes key shape can bump it, and old tabs simply stop hearing the new ones rather than acting on prefixes they would interpret differently. The TAB_ID is generated once per document rather than per channel, which is what makes the guard in Step 4 work even when a second channel is opened in the same tab.


Step 2 — Publish a Prefix After Every Successful Mutation

The message names what changed. Hooking it to the mutation cache means no call site has to remember.

export function publishMutations(client: QueryClient, channel: BroadcastChannel) {
  return client.getMutationCache().subscribe((event) => {
    if (event.type !== 'updated' || event.mutation?.state.status !== 'success') return;

    const key = event.mutation.options.mutationKey;
    if (!key) return; // no key, nothing meaningful to broadcast

    // Broadcast the resource prefix, not the whole mutation key: peers care
    // that todos changed, not which mutation did it.
    const prefix = (key as unknown[]).slice(0, 2);
    channel.postMessage({ kind: 'invalidate', prefix, origin: TAB_ID } satisfies SyncMessage);
  });
}

Cache Behavior Analysis: Subscribing to the MutationCache rather than adding a broadcast to each mutation’s onSuccess means a mutation added next year is covered automatically, which is the difference between a mechanism and a convention. Slicing the mutation key to its first two elements maps a mutation onto a query prefix — this only works if both vocabularies share a namespace, which is another dividend of the discipline in Avoiding Query Key Collisions Across Features. The prefix must be structured-clone-safe, so a mutation key containing a Date or a class instance will either throw at postMessage or arrive as something that no longer matches.


Step 3 — Mark Stale on Receipt, Do Not Fetch

This is the single option that decides whether cross-tab sync is cheap or a storm.

export function subscribeSync(client: QueryClient, channel: BroadcastChannel) {
  channel.onmessage = (event: MessageEvent<SyncMessage>) => {
    const message = event.data;
    if (!message || message.origin === TAB_ID) return;

    if (message.kind === 'invalidate') {
      client.invalidateQueries({
        queryKey: message.prefix,
        // 'none' marks entries stale without issuing a request. The default,
        // 'active', would fetch in every tab that has the screen mounted —
        // including tabs the user cannot see.
        refetchType: 'none',
      });
    }

    if (message.kind === 'signout') {
      // The one message that must act immediately, not on focus.
      void client.cancelQueries();
      client.clear();
    }
  };
}

// The other half: a stale entry refetches the moment the tab is looked at.
const queryClient = new QueryClient({
  defaultOptions: { queries: { refetchOnWindowFocus: true, staleTime: 30_000 } },
});

Cache Behavior Analysis: A background tab does not unmount anything — its components stay mounted and its observers stay attached — so the default refetchType: 'active' treats it exactly like the foreground tab and issues a request. With 'none', the entry’s isStale becomes true and nothing else happens until refetchOnWindowFocus fires, which is precisely when the data is about to be looked at. The user-visible behaviour is identical and the traffic is divided by the number of open tabs, which is the entire argument for this option.

Requests triggered by one mutation, by tab count Bar chart of requests. With refetchType active, one tab produces 2 requests, three tabs produce 6 and six tabs produce 12. With refetchType none, all three cases produce 2. Requests triggered by one mutation, by tab count Board screen mounted in every tab, one updateTodo call 1 tab, refetchType active 2 requests 3 tabs, refetchType active 6 requests 6 tabs, refetchType active 12 requests 6 tabs, refetchType none 2 requests
The default option makes cross-tab sync cost scale with tab count. Marking stale makes it constant.

Step 4 — Suppress Echoes and Coalesce Bursts

Two guards make the channel robust: an origin check for self-delivery, and a short window that collapses a burst of related mutations.

export function createPublisher(channel: BroadcastChannel, windowMs = 120) {
  const queued = new Map<string, unknown[]>();
  let timer: number | undefined;

  const flush = () => {
    timer = undefined;
    for (const prefix of queued.values()) {
      channel.postMessage({ kind: 'invalidate', prefix, origin: TAB_ID } satisfies SyncMessage);
    }
    queued.clear();
  };

  return (prefix: readonly unknown[]) => {
    // Deduplicate by serialized prefix: ten todo edits in a burst become one
    // message, because the peers' reaction to each would be identical.
    queued.set(JSON.stringify(prefix), [...prefix]);
    if (timer === undefined) timer = window.setTimeout(flush, windowMs);
  };
}

// Receiving side: the origin guard is not optional. React strict mode opens
// the channel twice in development, so a tab genuinely does hear itself.
channel.onmessage = (event: MessageEvent<SyncMessage>) => {
  if (event.data?.origin === TAB_ID) return;
  handle(event.data);
};

Cache Behavior Analysis: The origin guard matters more in practice than the specification suggests: a BroadcastChannel does not deliver to the object that posted, but it does deliver to a second channel opened in the same document — which is exactly what React strict mode’s double effect invocation produces, so a tab invalidating in a loop is a common development-only symptom with a real cause. Coalescing on the publishing side rather than the receiving side means the channel carries fewer messages, and since the peers’ reaction to ten identical prefixes is one invalidation anyway, nothing is lost. The 120-millisecond window is short enough to feel instantaneous and long enough to absorb the burst a bulk action produces.

What to broadcast, and what to keep local Matrix of four kinds of cross-tab message with the recommended handling on receipt. What to broadcast, and what to keep local Broadcast? Peer behaviour on receipt A mutation succeeded Yes — the key prefix only Mark stale, refetch on focus Fresh response data No Peers refetch; cloning payloads costs every tab Sign-out or session expiry Yes Cancel queries, clear cache, redirect immediately Local UI state — a filter, a sidebar No Not server state; belongs in the URL or local storage
Only the sign-out row acts immediately. Everything else defers to focus, which is what keeps the mechanism cheap.

Edge Cases & Gotchas

Messages must survive structured clone. A prefix containing a Date arrives as a Date, which then does not match a key built from an ISO string. Normalize keys through the factory before publishing.

No delivery guarantee. A tab that is in the process of being frozen may never receive the message. Treat every broadcast as a hint, never as the only path — refetchOnWindowFocus is the backstop that makes missed messages harmless.

Service workers join too. A service worker opening the same channel name receives every message. Useful for cache warming, surprising if you did not expect it — namespace the channel if the worker should not hear application traffic.


Common Pitfalls & Resolutions

Observable Issue Root Cause Diagnostic Resolution
One mutation produces a burst of requests across tabs invalidateQueries used the default refetchType: 'active' Pass refetchType: 'none' on the receiving side
A tab invalidates in a loop in development Strict mode opened the channel twice, so the tab hears itself Compare an explicit origin id on every message
Broadcast prefixes match nothing The prefix serialized differently from the local key Build both through the same key factory
A bulk action floods the channel Every mutation publishes independently Coalesce by serialized prefix over a short window
postMessage throws The prefix contains something structured clone cannot copy Keep keys to plain JSON values

Frequently Asked Questions

Does BroadcastChannel deliver to the sending tab?

Not to the BroadcastChannel object that called postMessage — but it does deliver to any other channel with the same name, including a second one opened in the same document. That distinction matters because React strict mode invokes effects twice in development, so a channel opened in a useEffect genuinely exists twice in one tab and the tab hears itself. The result is an invalidation loop that only reproduces in development, which is a confusing thing to debug. An explicit origin id compared on every message costs nothing and removes the whole class of problem.

What can I put in a message?

Anything the structured clone algorithm can copy: plain objects, arrays, strings, numbers, Date, Map, Set, ArrayBuffer. Not functions, not DOM nodes, and not class instances — those arrive as plain objects with their methods stripped, which is worse than throwing because it fails silently. For query key prefixes the practical rule is stricter still: keep them to JSON values, because a Date that survives the clone as a Date will not match a key built from an ISO string on the other side, and you have traded a serialization error for a matching one.

Why does the receiving tab not refetch immediately?

Because it almost never should. A hidden tab has not unmounted anything — its components are still mounted and its observers are still attached — so invalidateQueries with the default refetchType: 'active' treats it identically to the foreground tab and issues a request for a screen nobody can see. With six tabs open that is six requests for one mutation. Marking stale instead defers the fetch to refetchOnWindowFocus, which fires at the exact moment the data is about to be looked at, so the user sees the same thing and the network sees a sixth of the traffic.