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.
Top comments (7)
Sending the invalidation fact instead of the new record keeps each tab responsible for its own fetch, auth, and error handling. The logout version is the stronger test, since a stale private view is worse than a stale project name.
Agreed. Logout makes the boundary clearer: the broadcast helps clear the UI in other tabs, but it isn't the security guard. A tab can miss the message, so the server still needs to reject requests after the session ends. Redirecting to login and clearing the private view are the app's job.
That missed-message case is the one to watch. If a tab never gets the logout broadcast, does it just sit on the private view until its next fetch?
Yes - if a tab misses the message, the private view can remain until the app detects that the session ended. Server-side revocation prevents further authorized requests, but it doesn't erase data already rendered or cached.
For sensitive views, I'd add explicit session revalidation on returning to the tab, keeping the private UI hidden while that check runs, then clearing private state and redirecting if the session is invalid. TanStack Query's focus refetching can help, but it's not a complete session guard. The broadcast improves logout UX; it can't guarantee immediate cleanup in a tab that misses it.
Revalidating on return closes the gap the broadcast leaves. Hiding the private view while that check runs is the part that matters.
The send the fact, not the data rule is the part I would steal first. Broadcasting the new record always tempted me, but each tab already knows its own filters and auth, so invalidating the key and letting it refetch is cleaner and harder to get wrong. The detail I would watch in a dashboard is the page in the query key: invalidating the parent key should refetch the page each tab is actually on, not drag every tab back to page one.
Exactly. With page and filters in the query key, invalidating ['projects'] lets each tab refetch its own view without resetting its pagination state. One useful detail: by default, only active queries refetch right away. The other matching queries are marked stale, so you're not fetching every cached page at once.