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.
Ordering decides whether the cache stays empty Above: the cache is cleared while a request is still in flight, and the response repopulates it 180 milliseconds later. Below: queries are cancelled first, so no response can arrive, and the cache stays empty through the redirect. Clearing a cache with a request in flight does not empty it clear first GET /me — still in flight clear() response lands — cache repopulated previous user's data cancel first GET /me — aborted cancel() clear() → removeClient() → location.replace() nothing can repopulate The window is only a few hundred milliseconds — which is exactly long enough to be missed in testing.
The failure is a race, so it reproduces intermittently and only on slow connections — the worst combination for finding it.

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.

The full sequence, with the exposure window closed Timeline of sign-out. At zero the button is clicked and the broadcast is published. At four milliseconds peers receive it. At six all in-flight requests are aborted. At nine both caches are cleared. At twelve the persisted snapshot is removed. At twenty every tab has been redirected. The full sequence, with the exposure window closed 0ms Broadcast published before any local teardown 4ms Peers receive structured clone delivered 6ms Requests aborted nothing can repopulate 9ms Caches cleared queries and mutations 20ms All tabs redirected history entry replaced
Every step is sub-frame. Anything that waits for focus leaves a window measured in minutes rather than milliseconds.

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.

Each teardown step and what it leaves behind if skipped Matrix of five teardown steps with what remains exposed when the step is omitted. Each teardown step and what it leaves behind if skipped Skipped What remains Broadcast to peers Other tabs keep rendering authenticated data indefinitely Cancel in-flight requests A response lands after the clear and repopulates the cache client.clear() Every mounted observer keeps reading the previous session removeClient() on the persister The next page load rehydrates the session you just cleared location.replace() The Back button returns to a rendered authenticated view
Skipping any one of these leaves a specific, reproducible exposure. The ordering matters as much as the steps.

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.