All writing

auth · browser apis · best practices

Keeping sessions in sync across tabs with BroadcastChannel

Ghost sessions, stale logins and random logouts all come from tabs that don’t talk to each other. Here’s how to fix them properly with BroadcastChannel and a few small rules.

18 Oct 20266 min read

01The bug every multi-tab app ships

You log out in one tab. You switch to another tab of the same app, and it still shows your dashboard, your name in the corner, and a "Save" button that happily fires a request with a session that no longer exists.

That's the quiet multi-tab problem. Each tab keeps its own copy of the auth state in memory, and nothing tells the others when it changes. The symptoms are familiar:

  • Ghost sessions: a tab keeps acting logged in after you logged out somewhere else.
  • Stale logins: you sign in in one tab while another is still sitting on the login page.
  • Random logouts: two tabs try to refresh the access token at the same moment, refresh-token rotation invalidates one of them, and the user gets kicked out for no visible reason.

The browser already has a clean fix for this: BroadcastChannel.

02What BroadcastChannel actually does

A BroadcastChannel is a named pipe between every tab, window, iframe and worker of the same origin. You open one with a name, post a message, and every other channel with that name receives it.

const channel = new BroadcastChannel('app:auth:v1');
channel.postMessage({ type: 'logout' });
channel.onmessage = (event) => console.log(event.data);

Three details matter more than they look:

  1. The sender doesn't hear itself. The channel object that posts a message never receives it. Other channel objects, even ones in the same tab, do.
  2. Messages are structured-cloned, not shared. You send data, not references.
  3. It's same-origin only. app.example.com and example.com are different channels, which is usually exactly what you want for auth.

03Send events, never secrets

The single most important rule: the channel carries signals, not credentials.

Never post an access token, refresh token or session id through the channel. Any script running on your origin, including one injected through an XSS hole, can open the same channel and listen. Keep tokens in httpOnly cookies where JavaScript can't read them at all, and broadcast only what happened:

  • { type: 'logout', reason: 'user' }
  • { type: 'login' }
  • { type: 'session-refreshed' }

Each tab treats a message as a hint and confirms the truth with the server, usually a quick request to your session endpoint. A message should only ever make a tab less trusting (clear local state, go to the login page), never more.

04A small, typed message protocol

Give the channel a versioned name and a typed message shape, and ignore anything you don't recognise. When you change the protocol later, bump the version so old and new tabs don't misread each other.

type AuthMessage =
  | { v: 1; type: 'logout'; reason: 'user' | 'expired' | 'revoked'; from: string }
  | { v: 1; type: 'login'; from: string }
  | { v: 1; type: 'session-refreshed'; from: string };

const TAB_ID = crypto.randomUUID();

function isAuthMessage(data: unknown): data is AuthMessage {
  return typeof data === 'object' && data !== null && (data as AuthMessage).v === 1;
}

The from field (a per-tab id) makes it easy to de-duplicate and to debug which tab started what.

Create the channel in one place (a single module or hook) and close it when that module unmounts. Scattering new BroadcastChannel(...) calls across components is how you end up with duplicate handlers and messages processed three times.

05Logging out, in the right order

Order matters. In the tab where the user clicks Log out:

  1. Tell the server first. Invalidate the session or refresh token server-side, so it's dead everywhere, not just hidden.
  2. Clear local state: in-memory stores, query caches, anything user-specific in localStorage.
  3. Broadcast { type: 'logout' }.
  4. Redirect to the login page with replace, so the back button can't reveal the old screen.

In every other tab, on receiving logout:

  1. Cancel in-flight requests (an AbortController per session makes this one line).
  2. Clear local state.
  3. Redirect to the login page.

What the receiving tabs must not do is call the logout endpoint again or re-broadcast. That's how you get logout loops and a storm of identical requests the moment someone signs out with ten tabs open.

Treat a received logout as final, but a received login as a reason to check. A tab that hears login should re-fetch the session from the server before showing anything private.

06Token refresh without the stampede

If access tokens expire every few minutes, every open tab notices at roughly the same time and fires its own refresh. With refresh-token rotation, the first refresh wins and every other one fails, and the losers log the user out.

BroadcastChannel tells tabs that a refresh happened, but it can't stop them from racing. For that, pair it with the Web Locks API, which gives you a lock shared across every tab of the origin:

async function refreshSession() {
  await navigator.locks.request('auth:refresh', async () => {
    // Inside the lock: maybe another tab already refreshed while we waited
    if (await sessionIsStillValid()) return;
    await fetch('/api/auth/refresh', { method: 'POST', credentials: 'include' });
    channel.postMessage({ v: 1, type: 'session-refreshed', from: TAB_ID });
  });
}

Only one tab enters the lock at a time. The others wait, then see the session is already fresh and skip the request. The broadcast lets idle tabs update their timers without asking the server at all.

07Idle timeouts across tabs

An "auto-logout after 15 minutes of inactivity" rule breaks badly with tabs: the user is busy in tab A while tab B decides they've been idle and logs everyone out.

Share activity instead. Each tab posts a throttled { type: 'activity' } message (at most once every 30 seconds or so) when it sees real input. Every tab resets its idle timer when it hears one. The session only times out when all tabs have been quiet.

08Edge cases worth handling

  • Back/forward cache. A page restored from the bfcache missed every message sent while it was frozen. Listen for pageshow with event.persisted === true and re-check the session.
  • Sleeping tabs. Background tabs can be throttled or discarded. Re-validate on visibilitychange when the tab becomes visible again.
  • Older browsers. Where BroadcastChannel isn't available, fall back to the storage event: write a short-lived key to localStorage and remove it straight away. Other tabs receive the event, the current tab doesn't.
  • Libraries that already do it. Auth libraries such as NextAuth sync sign-out across tabs on their own. Use their signOut() rather than clearing cookies by hand, and add your own channel only for app state they don't know about.

09Testing it properly

Multi-tab behaviour is easy to test end to end. In Playwright, open two pages in the same browser context (so they share cookies and the origin), log out in one, and assert that the other lands on the login page within a second, and that your logout endpoint was called exactly once.

Add one more test for the refresh race: let both tabs' tokens expire, trigger a request in each, and assert only one refresh call reaches the server.

10The short version

  • One versioned channel, created in one place, closed on unmount.
  • Broadcast events, never tokens. Confirm with the server.
  • Logout: server first, then clear, broadcast, redirect. Receivers never re-broadcast.
  • Refresh behind a Web Lock so only one tab refreshes.
  • Share activity so idle timeouts respect every tab.
  • Re-check on pageshow and visibilitychange, and fall back to the storage event.

Small pieces, but together they're the difference between an app that feels solid across tabs and one that randomly signs people out.

GojoLet’s read this one together.