You change a project name in one tab. You switch to another tab of the same app. The old name is still there.
TanStack Query keeps one cache per tab. A mutation in tab A updates tab A. Tab B has no idea until it refetches, and that can be a long time if the window is not refocused or the stale time is generous.
The fix is small: tell the other tabs that something changed, and let each of them refetch.
Send the fact, not the data
It is tempting to broadcast the new data and write it into every cache. I would not. Each tab already knows how to fetch the right data, with the right auth, the right filters and the right error handling. So send only "this query key is stale" and let each tab ask the server.
The sender does not receive its own message: the browser skips the BroadcastChannel object that sent it, and the hook also filters out messages from its own source. That means the tab that ran the mutation invalidates locally, and the message is for everyone else.
I used react-broadcast-sync, a small hook over the browser's BroadcastChannel API. I maintain it, so take that into account.
import { createContext, useContext, type ReactNode } from 'react'; import { useQueryClient, useMutation } from '@tanstack/react-query'; import { useBroadcastChannel } from 'react-broadcast-sync'; type Broadcast = (queryKey: readonly unknown[]) => void; const BroadcastInvalidate = createContext<Broadcast>(() => {}); // Mount once, inside <QueryClientProvider>. export function CrossTabInvalidation({ children }: { children: ReactNode }) { const queryClient = useQueryClient(); const { postMessage } = useBroadcastChannel('query-sync', { registeredTypes: ['invalidate'], onMessage: { invalidate: msg => { const queryKey = msg.message?.queryKey; if (Array.isArray(queryKey)) { queryClient.invalidateQueries({ queryKey }); } }, }, }); const broadcast: Broadcast = queryKey => postMessage('invalidate', { queryKey }); return <BroadcastInvalidate.Provider value={broadcast}>{children}</BroadcastInvalidate.Provider>; } // `api.renameProject` stands for your own request function. export function useRenameProject() { const queryClient = useQueryClient(); const broadcast = useContext(BroadcastInvalidate); return useMutation({ mutationFn: (input: { id: string; name: string }) => api.renameProject(input), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['projects'] }); // this tab broadcast(['projects']); // other tabs }, }); } Mount CrossTabInvalidation once inside your QueryClientProvider. The listener has to live there, not inside the mutation hook, or a tab that never runs that mutation would not be listening.
That is the whole pattern. Tab B receives invalidate, calls invalidateQueries, and its active ['projects'] queries refetch.
Things that are easy to get wrong
- Only active queries refetch right away. Matching queries are marked stale. Active ones refetch now, inactive ones refetch when a component uses them next, and queries with
enabled: falseare not refetched. Check yourenabledandstaleTimesettings if something does not refresh. - No replay. A tab opened after the message never sees it, so it relies on its own fetch. With a normal in-memory cache that means fresh data. If you persist the query cache, restored data is only as fresh as your
staleTimeandgcTimeallow. - Query keys must be cloneable. Strings, numbers, plain arrays and objects. No functions.
- Mount the listener once, near your
QueryClientProvider.
Logging out everywhere
The same idea works for logout. Log out in one tab, and the others should stop showing private data.
Call the logout hook once in a component that every tab mounts (the app root), not next to the button, or other tabs would not be listening. Order matters: end the session on the server first, then broadcast, then clean up this tab. The message only changes what other tabs show. A tab that was closed or frozen by the browser gets nothing, so the server session is what protects the data. The next request from that tab should fail and send the user to the login page. Do not put tokens or user data in the message.
I wrote the full version as a recipe with the code: the Cross-tab Logout recipe in the docs.
Recipes
- TanStack Query invalidation across tabs (docs recipe)
- Cross-tab logout (docs recipe)
If you handle this differently, for example with a shared worker or a server push, I would like to hear what made you pick it.