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.
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.
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.
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.
Related
- Cross-Tab Cache Synchronization — the parent guide on making several tabs behave like one client.
- Electing a Leader Tab for Refetching — the polling half, which broadcasting does not address.
- Handling Auth Logout Across Tabs — the one message that must act immediately on receipt.
- Refetch on Window Focus and Reconnect — the trigger that turns a stale mark into a refetch.