Cross-Tab Cache Synchronization

Every open tab runs its own QueryClient with its own copy of the cache. Nothing connects them. A user who edits a record in one tab and switches to another sees the old value, and a user with six tabs open makes six times the background traffic of a user with one. This guide, part of Cache Hydration & Persistence Strategies, covers the messaging layer that makes several tabs behave like one client — and the failure modes that a naive implementation introduces. It shares its foundations with Offline Cache Persistence: both depend on a serializable view of cache state and a stable identity for each entry.

The order of the sections is the order to implement in. Invalidation broadcasting solves the correctness problem. Leader election solves the traffic problem. Auth propagation solves the security problem — and is the one to do first if your application handles anything sensitive.

Diagnostic Checklist

Prerequisites

You need a working understanding of how a persisted cache is serialized and restored, covered in Persisting React Query Cache to IndexedDB, and how invalidation propagates through a key hierarchy, covered in Invalidating Queries With a Key Factory. Cross-tab messaging carries key prefixes between tabs, so the keys must mean the same thing in each.

Cross-tab messaging topology Three tabs each hold their own query client and cache. A shared broadcast channel connects them. The leftmost tab is the elected leader and is the only one polling the network; the other two receive invalidation messages over the channel and mark their entries stale without refetching until focused. One leader polls; every tab listens Tab A — leader own QueryClient + cache runs refetchInterval holds the Web Lock visible, focused Tab B — follower own QueryClient + cache interval suspended marks stale only hidden Tab C — follower own QueryClient + cache interval suspended refetches on focus hidden BroadcastChannel("cache-sync") carries key prefixes and auth transitions — never cache payloads network one poll per user, not per tab followers make no background requests at all they refetch the moment they become visible, and not before
The channel carries intent. Keeping payloads off it is what makes the cost independent of how many tabs are open.

Implementation 1 — Broadcast Invalidation Intent

The correct message is “this key prefix changed”, not “here is the new data”. It is small, ordering-insensitive, and lets each tab decide whether it cares.

  1. Open one channel per application, named after your app and cache schema version.
  2. Publish a serializable key prefix after every successful mutation.
  3. On receipt, invalidate with refetchType: 'none' so hidden tabs mark stale without fetching.
  4. Guard against echo — a tab must ignore its own messages, which BroadcastChannel does not do for you across duplicated clients.
import type { QueryClient } from '@tanstack/react-query';

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

const TAB_ID = crypto.randomUUID();

export function connectCacheSync(client: QueryClient, channelName = 'cache-sync/v1') {
  const channel = new BroadcastChannel(channelName);

  channel.onmessage = (event: MessageEvent<SyncMessage>) => {
    const message = event.data;
    if (message.origin === TAB_ID) return; // never react to our own broadcast

    if (message.kind === 'invalidate') {
      client.invalidateQueries({
        queryKey: message.prefix,
        // Mark stale without fetching. A hidden tab has no reason to spend a
        // request on data nobody is looking at; refetchOnWindowFocus covers it.
        refetchType: 'none',
      });
    }

    if (message.kind === 'signout') {
      client.clear();
    }
  };

  return {
    publishInvalidate(prefix: readonly unknown[]) {
      channel.postMessage({ kind: 'invalidate', prefix: [...prefix], origin: TAB_ID } satisfies SyncMessage);
    },
    publishSignout() {
      channel.postMessage({ kind: 'signout', origin: TAB_ID } satisfies SyncMessage);
    },
    close: () => channel.close(),
  };
}

Cache Behavior Analysis: refetchType: 'none' is the load-bearing option. The default, 'active', refetches every query with a mounted observer — and a background tab still has mounted observers, so the default turns one mutation into one refetch per open tab, all firing within a few milliseconds of each other. Marking stale instead defers the cost to refetchOnWindowFocus, which fires exactly when the data is about to be looked at. The origin guard is necessary because a tab that opens the channel twice — common under React strict mode in development — will otherwise receive its own messages and invalidate in a loop.

Configuration Trade-offs:

  • Prefixes must be JSON-serializable to survive structuredClone. A key containing a Date or a class instance will either throw or arrive as something that no longer matches — one more reason to normalize keys as described in Cache Key Serialization & Hashing.
  • Naming the channel with a schema version means a deployed change to key structure does not have old tabs sending messages the new code misinterprets.
  • Broadcasting on every mutation is simplest; broadcasting only on mutations that changed something is better but requires the server to tell you.
Background requests per minute for one user, by tab count Bar chart comparing request volume with no coordination against leader election, at one, three and six open tabs. Uncoordinated volume rises from 2 to 6 to 12 requests per minute; with a leader it stays at 2. Background requests per minute for one user, by tab count 30-second refetchInterval on a dashboard, measured at the server 1 tab, uncoordinated 2 req/min 3 tabs, uncoordinated 6 req/min 6 tabs, uncoordinated 12 req/min 6 tabs, leader elected 2 req/min
Invalidation broadcasting does not change these numbers — only leader election does. Polling is per tab until something stops it.

Implementation 2 — Elect a Leader for Background Work

refetchInterval fires in every tab independently. The Web Locks API gives you a leader with no heartbeat protocol and no stale-lock cleanup, because the browser releases the lock when the tab goes away.

  1. Request a named lock that is never released voluntarily.
  2. Treat acquiring it as becoming leader; the promise simply never resolves while you hold it.
  3. Expose leadership as reactive state that query options can read.
  4. Let followers keep their intervals disabled until they inherit the lock.
import { useEffect, useState } from 'react';

export function useIsLeader(lockName = 'cache-sync-leader'): boolean {
  const [isLeader, setIsLeader] = useState(false);

  useEffect(() => {
    if (!('locks' in navigator)) {
      // No Web Locks: degrade to every tab acting as leader rather than none.
      setIsLeader(true);
      return;
    }

    const controller = new AbortController();

    navigator.locks
      .request(lockName, { signal: controller.signal }, () => {
        setIsLeader(true);
        // Holding the lock for as long as this tab lives. The browser releases
        // it automatically on close or crash, and the next waiter is promoted.
        return new Promise<never>(() => {});
      })
      .catch(() => {
        // AbortError on unmount — not a failure.
      });

    return () => {
      controller.abort();
      setIsLeader(false);
    };
  }, [lockName]);

  return isLeader;
}

export function useDashboardStats() {
  const isLeader = useIsLeader();

  return useQuery({
    queryKey: ['dashboard', 'stats'],
    queryFn: fetchDashboardStats,
    // Only the leader polls. Followers still read from their own cache and
    // still refetch on focus, so nothing looks stale to the user.
    refetchInterval: isLeader ? 30_000 : false,
    refetchOnWindowFocus: true,
    staleTime: 25_000,
  });
}

Cache Behavior Analysis: Returning a never-resolving promise from the lock callback holds the lock for the tab’s lifetime; the browser reclaims it on close, crash or navigation, and immediately grants it to the longest-waiting tab, so leadership transfers within a frame of the leader disappearing. Setting refetchInterval: false in followers does not disable their cache — they still serve data from their own entries and still honour refetchOnWindowFocus, so a follower the user switches to refreshes on arrival rather than showing whatever it last polled. Keeping staleTime just under the interval means the leader’s scheduled refetch always finds the entry stale and actually runs, instead of being skipped as fresh.

Configuration Trade-offs:

  • Web Locks is unavailable in some older webviews. Degrading to “everyone is leader” preserves correctness and loses the traffic saving; degrading to “nobody is leader” would silently stop polling, which is worse.
  • Leadership does not make the leader’s cache authoritative. Followers still need Implementation 1 to learn that something changed.
  • A single leader for all background work is simple. Per-resource leaders spread the load across tabs, at the cost of a lock per resource and much harder debugging.

Implementation 3 — Propagate Sign-Out Immediately

An authentication transition is the one message that must not wait for focus. A tab still rendering a previous user’s data is a data-exposure incident, not a staleness bug.

  1. Broadcast the transition before clearing local state, so peers start their teardown in parallel.
  2. On receipt, clear the cache synchronously and cancel every in-flight request.
  3. Clear persisted storage too, or the next reload rehydrates what you just removed.
  4. Navigate to a safe route rather than leaving components mounted against an empty cache.
import type { QueryClient } from '@tanstack/react-query';

export async function signOutEverywhere(
  client: QueryClient,
  sync: { publishSignout: () => void },
  persister: { removeClient: () => Promise<void> },
) {
  // 1. Tell the other tabs first — their teardown overlaps ours.
  sync.publishSignout();

  // 2. Stop anything in flight before it can write to a cleared cache.
  await client.cancelQueries();

  // 3. Drop in-memory state. clear() removes queries and mutations together.
  client.clear();

  // 4. Remove the persisted snapshot, or the next load restores this session.
  await persister.removeClient();

  // 5. Leave the authenticated surface entirely.
  window.location.replace('/signed-out');
}

export function handleRemoteSignout(client: QueryClient, persister: { removeClient: () => Promise<void> }) {
  client.cancelQueries();
  client.clear();
  void persister.removeClient();
  // A hard navigation guarantees no component keeps a stale render tree alive.
  window.location.replace('/signed-out');
}

Cache Behavior Analysis: Ordering matters throughout. cancelQueries before clear() prevents the case where a request that was already in flight resolves after the clear and repopulates the cache with the previous user’s data — a subtle re-exposure that only appears under slow networks. client.clear() removes queries and mutations but does not touch the persister, so skipping removeClient() means the next page load rehydrates the very data you cleared. Using location.replace rather than a client-side route push removes the entry from history, so the browser Back button cannot return to a rendered authenticated view.

Configuration Trade-offs:

  • A hard navigation loses in-memory application state and any unsaved draft. For sign-out that is correct; do not copy the pattern for softer transitions.
  • Broadcasting before clearing gives peers a head start but means a crash mid-teardown leaves this tab dirty while peers are clean. Clearing first inverts the risk; broadcasting first is the safer default because peers are the exposure surface.
  • Sign-in transitions deserve the same treatment in reverse — a stale cache from the previous user must be cleared before the new user’s queries mount.
Sign-out propagation across three tabs Timeline showing sign-out clicked in tab A at zero milliseconds, the broadcast delivered to tabs B and C at four milliseconds, in-flight requests cancelled at six, caches cleared at nine, and all tabs on the signed-out route by twenty milliseconds. Sign-out propagation across three tabs 0ms Sign-out clicked tab A publishes first 4ms Peers receive structured clone delivered 6ms Requests cancelled nothing can repopulate 9ms Caches cleared memory and persister 20ms All tabs redirected history entry replaced
The whole sequence is sub-frame. Anything that waits for focus is a window in which another tab still renders authenticated data.

Implementation 4 — Fall Back to Storage Events

Where BroadcastChannel is unavailable, a localStorage write raises a storage event in every other same-origin tab. That is a usable one-way bus in about fifteen lines.

  1. Feature-detect BroadcastChannel and pick a transport once, at startup.
  2. Write a message envelope with a nonce so repeated identical messages still fire an event.
  3. Read the payload in the storage handler, ignoring unrelated keys.
  4. Present both transports behind the same interface so calling code never branches.
type Transport = { post: (message: unknown) => void; onMessage: (fn: (message: unknown) => void) => void; close: () => void };

export function createTransport(name: string): Transport {
  if ('BroadcastChannel' in globalThis) {
    const channel = new BroadcastChannel(name);
    return {
      post: (message) => channel.postMessage(message),
      onMessage: (fn) => { channel.onmessage = (event) => fn(event.data); },
      close: () => channel.close(),
    };
  }

  // Fallback: storage events fire in other tabs, never in the writer.
  let handler: ((message: unknown) => void) | null = null;
  const onStorage = (event: StorageEvent) => {
    if (event.key !== name || !event.newValue) return;
    try {
      handler?.(JSON.parse(event.newValue).payload);
    } catch {
      /* a partially written value from a tab that was closed mid-write */
    }
  };
  window.addEventListener('storage', onStorage);

  return {
    // The nonce guarantees the value differs each time; writing an identical
    // value raises no storage event at all.
    post: (message) => localStorage.setItem(name, JSON.stringify({ nonce: crypto.randomUUID(), payload: message })),
    onMessage: (fn) => { handler = fn; },
    close: () => window.removeEventListener('storage', onStorage),
  };
}

Cache Behavior Analysis: The storage event fires only in other tabs of the same origin, which gives you the echo suppression that BroadcastChannel does not — but it also means you cannot use it to talk to yourself, so any self-notification must be a direct call. The nonce exists because browsers suppress the event when setItem writes a value identical to the current one; without it, invalidating the same prefix twice in a row silently delivers once. Wrapping the parse in a try handles the real case of a tab closing mid-write and leaving truncated JSON behind, which would otherwise throw inside an event handler and take down the listener.

Configuration Trade-offs:

  • localStorage is synchronous and blocks the main thread. Keep messages small; never use this transport for payloads.
  • Safari in private browsing historically threw on setItem once quota was reached. Wrap post in a try/catch if you support it.
  • The fallback has no delivery guarantee and no ordering guarantee between keys. Treat every message as a hint to revalidate, which is exactly how Implementation 1 uses it.

Common Pitfalls & Resolutions

Observable Issue Root Cause Diagnostic Resolution
One mutation causes a burst of identical requests across tabs Broadcast invalidation used the default refetchType: 'active' Pass refetchType: 'none' and rely on focus refetching — see Syncing Query Cache Across Tabs With BroadcastChannel
Background traffic scales with open tabs refetchInterval runs independently in each tab Gate the interval on leadership — see Electing a Leader Tab for Refetching
A tab still shows the previous user after sign-out Sign-out cleared only the tab that initiated it Broadcast the transition and clear the persister too — see Handling Auth Logout Across Tabs
A message is delivered once when it was sent twice The storage fallback wrote an identical value, which raises no event Include a nonce in every envelope
Invalidation messages arrive but match nothing The broadcast prefix serialized differently from the local key Normalize keys through a factory so both tabs build identical arrays
A stale persisted snapshot overwrites a newer one Two tabs wrote the persister without coordinating Let the leader own persistence, or stamp snapshots and refuse older writes

Frequently Asked Questions

Should I broadcast cache data or just invalidation messages?

Broadcast intent, not payloads. Sending data means every tab pays the structured-clone cost of every update — including tabs that have no observer for that key and will never render it — and it forces you to reason about message ordering between tabs, because two payloads can arrive in the wrong order. Sending “this prefix changed” is a handful of bytes, is idempotent, and lets each tab apply its own freshness policy. The exception is a genuinely expensive query whose result you already have and whose refetch you want to avoid; even then, prefer having the follower read from a shared persisted snapshot.

Why do all my tabs refetch at once after a mutation?

Because invalidateQueries defaults to refetchType: 'active', which refetches every matching query that has a mounted observer — and a background tab still has mounted observers; being hidden does not unmount anything. So one mutation broadcast to five tabs produces five simultaneous requests. Passing refetchType: 'none' marks the entries stale without fetching, and refetchOnWindowFocus then refetches exactly when a tab is about to be looked at. The user-visible behaviour is identical and the traffic is a fifth.

Do I need leader election if I already broadcast invalidations?

Yes, if you poll. Broadcasting solves duplicated work triggered by mutations; it does nothing about refetchInterval, which is a timer running independently in every tab. Six tabs open on a dashboard with a 30-second interval is twelve requests a minute for a single user who is looking at one of them. Leader election is also what makes a shared persisted cache safe, because it gives you one writer instead of a race between however many tabs happen to be open.

Is BroadcastChannel available everywhere I need it?

It is supported in all current major browsers, but there are real gaps: it does not cross origins, some embedded webviews omit it, and a page and its service worker only share a channel if both explicitly open one by the same name. The localStorage storage-event fallback in Implementation 4 covers those gaps in about fifteen lines and has the useful property of never echoing to the sender. Feature-detect once at startup and hide the choice behind a single transport interface, so the calling code never has to know which one it got.