This page zooms in on one scenario inside the broader Cross-Tab State Synchronization guide: keeping authentication state consistent the instant it changes in any open tab.
The exact failure this page solves #
A user has your app open in three tabs — one left over from a support call, one they’re actively using, one in a background pinned tab. They click “Log out” in the active tab. The active tab redirects to /login correctly. The other two tabs do nothing: their in-memory auth store still holds the old user object, their UI still renders account menus and protected routes, and any request they fire still carries the (now revoked) access token until the user manually reloads or hits a 401. That’s a real security hazard on shared or public machines, and it’s confusing on personal ones — the same problem, mirrored, happens on login: a user signs in from one tab while a stale “please log in” screen sits in another, and on silent token refresh, where one tab quietly rotates the access token and the others keep using an expired one until their next request fails.
The fix is a small auth-sync module built on BroadcastChannel, which every tab on the same origin can subscribe to without any server round-trip. One tab posts a { type: 'logout' | 'login' | 'token-refresh' } message; every other tab’s listener updates its own in-memory store and, for logout, redirects immediately. A storage event listener rides alongside it as a fallback for the rare case where BroadcastChannel itself is unavailable or has been torn down.
Zero-to-working auth-sync module #
The module below is self-contained: it owns the channel, the storage fallback, echo suppression, and the three event handlers. It assumes an authStore object (whatever your app already uses — a small class, a Zustand store, a plain module-level object) exposing clear(), setUser(), and setToken().
// auth-sync.ts
const CHANNEL_NAME = 'auth';
const STORAGE_KEY = '__auth_sync__';
const TAB_ID = crypto.randomUUID(); // unique per tab, regenerated per load
type AuthEvent =
| { type: 'logout'; tabId: string }
| { type: 'login'; tabId: string; user: { id: string; email: string } }
| { type: 'token-refresh'; tabId: string; token: string };
let channel: BroadcastChannel | null = null;
try {
channel = new BroadcastChannel(CHANNEL_NAME);
} catch {
channel = null; // unsupported context — storage fallback still covers us
}
function applyEvent(event: AuthEvent): void {
if (event.tabId === TAB_ID) return; // echo suppression: skip our own broadcast
switch (event.type) {
case 'logout':
authStore.clear();
window.location.assign('/login');
break;
case 'login':
authStore.setUser(event.user);
break;
case 'token-refresh':
if (authStore.isCleared()) return; // logout already won this race
authStore.setToken(event.token);
break;
}
}
channel?.addEventListener('message', (e: MessageEvent<AuthEvent>) => applyEvent(e.data));
// Fallback: mirrors every broadcast through localStorage for contexts
// where BroadcastChannel never delivered (see edge cases below).
window.addEventListener('storage', (e: StorageEvent) => {
if (e.key !== STORAGE_KEY || !e.newValue) return;
applyEvent(JSON.parse(e.newValue) as AuthEvent);
});
// Re-arm the channel if the browser discarded it while this tab was
// frozen or served from the back/forward cache.
window.addEventListener('pageshow', (e: PageTransitionEvent) => {
if (e.persisted && channel === null) {
try { channel = new BroadcastChannel(CHANNEL_NAME); } catch { /* still unsupported */ }
}
});
export function broadcastAuth(event: Omit<AuthEvent, 'tabId'>): void {
const full = { ...event, tabId: TAB_ID } as AuthEvent;
channel?.postMessage(full);
localStorage.setItem(STORAGE_KEY, JSON.stringify(full));
localStorage.removeItem(STORAGE_KEY); // clears immediately so a repeat value still fires 'storage'
}
export function logout(): void {
authStore.clear();
broadcastAuth({ type: 'logout' });
window.location.assign('/login');
}
Step-by-step explanation #
-
Open a shared channel and tag this tab.
new BroadcastChannel('auth')creates (or joins) a same-origin channel that every tab with this script loaded can post to and listen on.TAB_IDis generated once per page load viacrypto.randomUUID()and stamped onto every outgoing event. -
Mirror every post to
localStorage.broadcastAuth()writes the same payload toSTORAGE_KEYimmediately afterpostMessage, then deletes it. The delete-after-write is deliberate:storageonly fires when the value actually changes, so writing the same JSON twice in a row (two logouts back to back) would otherwise fire only once. -
Suppress echoes by tab id. Both the
messagelistener and thestoragelistener call the sameapplyEvent(), which discards anything carrying this tab’s ownTAB_ID. Without that check, the tab that initiated the logout would also receive its own broadcast back and redirect twice, or briefly re-render a cleared store against local state that hasn’t caught up yet. -
Apply the event to the in-memory auth store.
logoutcallsauthStore.clear()then redirects withwindow.location.assign('/login')rather than a client-side router push, so that even a tab holding stale in-memory route guards ends up on a fresh navigation.loginandtoken-refreshmutate the store in place without navigating — the current page keeps rendering, just with fresh credentials. -
Guard the token-refresh race against logout. The
token-refreshbranch checksauthStore.isCleared()before writing. If a refresh event lands in a tab that already processed a logout (network delivery order isn’t guaranteed under load), the check prevents resurrecting a token into a store the user just cleared. -
Recreate the channel after a tab is discarded. Some browsers close a
BroadcastChannelinstance when a tab is frozen for memory pressure or served from the back/forward cache. Thepageshowlistener withevent.persisted === truere-opens the channel so a tab that “wakes up” after being backgrounded doesn’t silently miss subsequent events.
Every call site that changes auth state should route through broadcastAuth() rather than mutating authStore directly — including the tab where the change originates — so all tabs, including the initiating one, follow the exact same code path.
Edge cases #
| Scenario | Symptom | Mitigation |
|---|---|---|
BroadcastChannel closed when a tab is discarded |
Backgrounded tab misses a logout event entirely and still shows an authenticated UI on return | Re-open the channel in a pageshow listener keyed on event.persisted, and also re-validate the token against the auth store on every tab focus as a belt-and-braces check |
| Echo of a tab’s own broadcast | Initiating tab double-processes its own logout, causing a duplicate redirect or a flash of cleared state before the local mutation lands | Tag every event with TAB_ID and discard events whose tabId matches the current tab in applyEvent() |
| Race with an in-flight request during logout | A request started just before logout completes after the store is cleared, and its success handler repopulates UI with data scoped to the now-invalid session | Capture the token at request-issue time, not response-handle time; in the response handler, compare it against the store’s current token and drop the response if they differ |
storage event fallback quirks |
Fallback never fires because the write value is unchanged, or fires twice because of the write-then-delete pattern, or never fires in a private-browsing partition | Always follow a setItem with an immediate removeItem so repeat values still trigger the event; treat the fallback as best-effort only, never as the sole delivery path |
Verification steps #
- Open two tabs to the same origin, both authenticated. Confirm both show the same user in their account menu.
- Log out in tab A. Tab A should redirect to
/loginimmediately. Switch to tab B without reloading it — it should also show the logged-out UI (redirected or gated) within one event-loop tick of switching focus, not just on next reload. - Check the Console in tab B for a log line (add a temporary
console.debug('[auth-sync] applied', event)insideapplyEvent) confirming thelogoutevent was received with atabIddifferent from tab B’s own. - Repeat for login: log out both tabs, then log in via tab A only. Tab B should reflect the new session without a manual refresh.
- Simulate the discard case in DevTools: open the Application panel, use “Freeze” under the Frame lifecycle (or navigate away and back to trigger
pageshowwithpersisted: true), then trigger a logout from another tab and confirm the frozen tab still redirects once it becomes active again. - Confirm the fallback independently by temporarily commenting out the
BroadcastChannelconstruction (forcingchannelto staynull) and re-running step 2 — thestoragelistener alone should still propagate the logout.
Frequently asked questions #
Why not just rely on the storage event instead of BroadcastChannel?
The storage event only fires in tabs other than the one that wrote the value, never fires when the newly written value equals the current one, and is unreliable or entirely absent in some private-browsing contexts where storage is partitioned per tab. BroadcastChannel delivers to every same-origin listener regardless of payload equality, which is why it’s the primary channel here and storage is kept only as a fallback.
Will a background tab actually receive the logout message?
Yes, provided the tab’s page is still resident in memory — background tabs keep running their event loop, just at a throttled timer rate that doesn’t affect BroadcastChannel message delivery. The message is only lost if the browser has fully discarded or frozen the tab’s execution context, which is exactly the case the pageshow/persisted re-arm logic in the module handles.
What happens if a token-refresh and a logout race each other?
Always let logout win. Because logout clears the entire store rather than patching a single field, a token-refresh event landing after logout has nothing valid to write into; the isCleared() guard in the token-refresh branch makes that explicit instead of relying on message ordering, which BroadcastChannel does not strictly guarantee across tabs under load.
Related #
- Cross-Tab State Synchronization — parent guide covering the broader coordination patterns this module draws on
- LocalStorage & IndexedDB Sync Strategies — storage-tier trade-offs behind the
storageevent fallback used here - Session State Persistence & Hydration Fallbacks — the top-level coverage of client-side durability this scenario sits under