Handling Auth Logout Across Tabs
Every other cross-tab message can wait for focus. This one cannot. A tab that keeps rendering a signed-out user’s data — their records, their names, their numbers — is a data-exposure problem, and on a shared machine it is the exact failure the sign-out button was supposed to prevent. This page is the teardown ordering that makes it reliable, under Cross-Tab Cache Synchronization. It uses the channel from Syncing Query Cache Across Tabs With BroadcastChannel but deliberately breaks its rule about deferring to focus, and it interacts with the persisted snapshot discussed in Cache Versioning & Migration.
Diagnostic Checklist
Prerequisites
- A working cross-tab channel.
- A persister you can call
removeClient()on, if the cache is persisted. - A signed-out route that renders without any authenticated data.
Step 1 — Broadcast Before Tearing Down
Peers should start their teardown in parallel with yours, not after it.
export async function signOutEverywhere(
client: QueryClient,
channel: BroadcastChannel,
persister: { removeClient: () => Promise<void> },
) {
// 1. Tell the other tabs first. Their teardown overlaps ours, which shortens
// the window in which any tab still shows authenticated data.
channel.postMessage({ kind: 'signout', origin: TAB_ID } satisfies SyncMessage);
await teardown(client, persister);
// 5. Leave the authenticated surface entirely.
window.location.replace('/signed-out');
}
export function handleRemoteSignout(
client: QueryClient,
persister: { removeClient: () => Promise<void> },
) {
// A peer's teardown is identical, minus the broadcast.
void teardown(client, persister).then(() => window.location.replace('/signed-out'));
}
Cache Behavior Analysis: Broadcasting first means the exposure window is bounded by the slowest peer’s teardown rather than by the sum of yours and theirs — with structured-clone delivery taking single-digit milliseconds, every tab is typically clean within one frame. The trade is that a crash between the broadcast and the local teardown leaves this tab dirty while its peers are clean, which is the safer direction: the peers are the tabs the user is not looking at and therefore the ones most likely to be forgotten. Note that handleRemoteSignout does not broadcast, which is what stops a message loop between tabs.
Step 2 — Cancel In-Flight Requests Before Clearing
This is the ordering that matters. A request already on the wire will write to the cache when it resolves, whether or not you cleared it a moment earlier.
async function teardown(client: QueryClient, persister: { removeClient: () => Promise<void> }) {
// 2. Abort everything in flight. Without this, a GET issued 200ms ago
// resolves after the clear and repopulates the cache.
await client.cancelQueries();
await client.cancelMutations?.();
// 3. Drop in-memory state. clear() removes queries AND mutations.
client.clear();
// 4. Remove the persisted snapshot, or the next load restores this session.
await persister.removeClient();
}
Cache Behavior Analysis: cancelQueries with no filter aborts every in-flight fetch and returns each query’s fetchStatus to idle; because the abort propagates through the promise chain, awaiting it guarantees no response is still pending when clear() runs. client.clear() empties both the QueryCache and the MutationCache, which matters because a pending mutation holds its own optimistic context and would otherwise fire onSettled against a cleared cache. The window this closes is only a few hundred milliseconds wide, which is precisely why it survives testing: on a fast connection there is rarely anything in flight to race with.
Step 3 — Clear Persisted State Too
An in-memory clear that leaves a snapshot on disk is undone by the next page load.
export async function purgePersistedState(persister: { removeClient: () => Promise<void> }) {
// The persister's own key.
await persister.removeClient();
// Anything else the application wrote alongside it. Enumerate explicitly —
// clearing all of localStorage removes unrelated preferences too.
for (const key of ['app-cache', 'app-cache:mutations', 'draft:composer']) {
try {
localStorage.removeItem(key);
} catch {
/* private mode or quota-restricted storage */
}
}
// IndexedDB stores are not covered by localStorage.removeItem.
await new Promise<void>((resolve) => {
const request = indexedDB.deleteDatabase('app-query-cache');
request.onsuccess = () => resolve();
request.onerror = () => resolve(); // best effort; do not block sign-out
request.onblocked = () => resolve(); // another tab still has it open
});
}
Cache Behavior Analysis: removeClient() deletes the persister’s own record but nothing else, so an application that also persists a mutation queue or a draft has to enumerate those separately — and enumerating is better than localStorage.clear(), which would also remove theme preferences and consent flags that have nothing to do with the session. deleteDatabase fires onblocked when another tab still holds a connection, which during a coordinated sign-out is likely; resolving on that event rather than waiting keeps sign-out from hanging, and the peer’s own teardown will close its connection a moment later. Treat every step here as best-effort: a failure to delete storage must never prevent the redirect.
Step 4 — Apply the Same Ordering to Sign-In
The reverse case is less alarming and equally wrong: a cache left from the previous user, read by the new one’s first render.
export async function signInAs(
credentials: Credentials,
client: QueryClient,
channel: BroadcastChannel,
persister: { removeClient: () => Promise<void> },
) {
// Whatever is cached belongs to whoever was here before — possibly nobody,
// possibly a different account. Either way it is not this user's.
await client.cancelQueries();
client.clear();
await persister.removeClient();
const session = await authenticate(credentials);
// Seed only what the new identity owns.
client.setQueryData(['session', 'me'], session.user);
// Peers may be on a signed-out screen; tell them to reload into the session.
channel.postMessage({ kind: 'signout', origin: TAB_ID } satisfies SyncMessage);
window.location.replace('/');
}
Cache Behavior Analysis: Clearing before authenticating rather than after is deliberate: it means there is no moment at which the new session’s token could be used to refetch into a cache still holding the previous user’s entries, which is how account-switching bugs produce genuinely mixed data. Seeding ['session','me'] immediately after authentication avoids a redundant round trip on the first render without reintroducing anything stale, since the value comes from the sign-in response itself. Reusing the signout message for peers is a small simplification that works because a signed-out peer reloading into an authenticated session is the desired outcome — if your signed-out route does not redirect on an active session, send a distinct message instead.
Edge Cases & Gotchas
A 401 is also a sign-out. A token that expires mid-session should run the same teardown, triggered from the transport’s error path rather than from a button. Route both through one function so they cannot diverge.
Component-local copies survive a clear. A component that copied query data into useState or a ref still renders it after client.clear(). That is the strongest argument for a hard navigation rather than a client-side route change.
Service workers cache responses too. If a service worker caches authenticated API responses, clearing the query cache does not touch them. Send it a message to purge its own caches as part of teardown.
Common Pitfalls & Resolutions
| Observable Issue | Root Cause | Diagnostic Resolution |
|---|---|---|
| Another tab still shows authenticated data | The sign-out was local to the tab that initiated it | Broadcast the transition before tearing down |
| The cache refills moments after being cleared | A request in flight resolved after clear() |
await cancelQueries() first |
| Reloading restores the previous session | The persisted snapshot was never removed | Call removeClient() and delete auxiliary stores |
| Back returns to a rendered dashboard | A client-side route push left the history entry intact | Use location.replace |
| A new sign-in shows the previous account’s records | The cache was cleared after authenticating, or not at all | Clear before authenticating |
Frequently Asked Questions
Why cancel queries before clearing the cache?
Because clear() empties what is currently stored and has no effect on what is currently arriving. A GET /me issued two hundred milliseconds before the sign-out click resolves shortly afterwards and writes into the cache exactly as it would have done normally — so the cache you just emptied now contains the previous user’s profile, and any component still mounted renders it. Cancelling first aborts those requests so no response can arrive. The window is small, which is why this bug survives testing on a fast connection and appears in production on a slow one.
Is clearing the cache enough, or do I need a hard navigation?
A hard navigation with location.replace is the reliable option, and the reason is that the cache is not the only place data lives. A component that copied a value into useState, a ref holding a previous response for comparison, a chart library retaining its last dataset — none of those are touched by client.clear(), and all of them keep rendering. replace also removes the authenticated page from the history stack, so the Back button cannot return to a rendered view. A client-side route change is faster and leaves all of that in place.
Does sign-in need the same treatment?
Yes, in reverse, and it is easier to overlook because the failure is less alarming than an exposure — it looks like a rendering glitch rather than a leak. If the cache still holds the previous user’s entries when the new session’s queries mount, the first render shows those entries under the new identity, and on a shared machine that is the same problem wearing a different hat. Clear before authenticating rather than after, so there is no interval in which a fresh token could be used to refetch into a cache that is still holding someone else’s data.
Related
- Cross-Tab Cache Synchronization — the parent guide on coordinating several tabs.
- Syncing Query Cache Across Tabs With BroadcastChannel — the channel this message travels over.
- Cache Versioning & Migration — the persisted snapshot that a sign-out has to remove.
- Cancelling In-Flight Queries With AbortController — the cancellation mechanics this teardown depends on.