-
Notifications
You must be signed in to change notification settings - Fork 0
Multi Tab Sync
Tabs coordinate auth over BroadcastChannel.
createClient({ multiTab: true }); // default
createClient({ multiTab: false }); // opt outThe channel name is ${storageKey}.auth, i.e. apiclient.auth by default.
Sync runs from wherever the client runs — including inside the Web Worker, which has its own BroadcastChannel.
Fixed in v1.0.2: the runtime check treated any scope without
windowas the server, and a worker has nowindow. So in the default worker mode the channel was never opened and cross-tab sync silently did nothing. Tabs only converged on the next 401.
Sync also relies on tabs sharing storage, so pair it with a persistent kind — with the default "memory" each tab keeps its own tokens and only logout propagates.
authMode: "cookie" works with multi-tab sync, and needs no storage setting: the httpOnly cookie is scoped to the origin, so every tab already shares one session by construction. The channel is only there to tell the other tabs that it changed.
| Event in tab A | Tab B |
|---|---|
login() |
becomes authenticated |
| refresh | stays authenticated |
logout() |
becomes logged out |
One caveat: state.user is not broadcast — it is never persisted or sent over the channel in either auth mode. A tab that learns about a login from a sibling knows it is authenticated but has no user object until it fetches one:
api.onAuthStateChange(async (state) => {
if (state.isAuthenticated && !state.user) {
await api.restoreSession("/api/auth/me"); // fills in `user`
}
});Fixed in v1.0.2: cookie mode has no shared storage to re-read, so the "another tab signed in" path did nothing and only
logoutpropagated.
| Without sync | With sync |
|---|---|
| Log out in tab A, tab B keeps making authenticated calls until its next 401 | Every tab clears immediately |
| Five tabs wake from sleep and all refresh at once | One tab leads; the others adopt the result |
| Tab A refreshes and rotates the refresh token; tab B's copy is now invalid | Tab B re-reads shared storage and stays current |
Three message types cross the channel. None carries a token.
type TabMessage =
| { type: "login"; tabId: string; expiresAt: number | null }
| { type: "refreshed"; tabId: string; expiresAt: number | null }
| { type: "logout"; tabId: string };Booleans, timestamps and a random tab id — that's it. A compromised tab learns nothing it doesn't already have.
Each tab ignores its own messages, matched on tabId.
| Received | Effect |
|---|---|
logout |
Clear tokens locally, fire onAuthFailure
|
login / refreshed
|
Re-hydrate from shared storage, then emit AuthState
|
Re-hydration is why storage kind matters: with "local" or "cookie" the other tab genuinely picks up the rotated tokens. With "memory" or "session" there's nothing shared to re-read, so each tab has its own session. An explicit logout() still signs every tab out. A failed refresh does not: it only ends that tab's own session, since the others never shared it.
| Storage |
logout() propagates |
A rejected refresh propagates | Refreshed tokens propagate |
|---|---|---|---|
"memory" |
✅ | ❌ (independent sessions) | ❌ (nothing shared) |
"session" |
✅ | ❌ (per-tab) | ❌ (per-tab) |
"local" |
✅ | ✅ | ✅ |
"cookie" |
✅ | ✅ | ✅ |
In authMode: "cookie" the browser already holds the rotated httpOnly cookie, so every tab is current by construction.
With a rotating refresh token, two tabs refreshing at once would present the same token twice; the server reads that as reuse and revokes the session. So refreshes are serialized across tabs with the Web Locks API (navigator.locks, available in windows and workers):
tab A ── lock ── refresh → new pair saved ── unlock
tab B ── wait ─────────────────────────────── lock ── sees A's new pair → adopts it, no request
Once it holds the lock, a tab first checks whether a sibling already refreshed while it waited — a newer refresh token in shared storage, or in cookie mode a sibling's refreshed message — and adopts that result instead of spending the token again. The winning tab persists its new tokens before releasing the lock, so the next tab always sees them.
The browser releases a lock when its tab closes, so a tab killed mid-refresh never blocks the others. Where Web Locks is missing, or multiTab is off, each tab simply refreshes on its own.
Within a single tab, refresh coalescing is exact: AuthStore.coalesceRefresh guarantees one call. See Token Refresh.
The channel is never opened when typeof window === "undefined". This is not cosmetic: Node's BroadcastChannel is a ref'd handle that keeps the event loop alive, which would hang SSR renders, CLIs and test runners forever.
Where the API exists in a non-browser runtime, the client also calls channel.unref?.() as a second line of defence.
- No
BroadcastChannel→ the client behaves as a single tab. - Channel construction throws → caught; sync is disabled.
-
postMessageon a closed channel → caught. - A listener throws → caught; the other listeners still run.
Two apps on the same origin — an admin panel and a customer app — should not share auth. Give them different storageKeys:
export const adminApi = createClient({ storageKey: "acme-admin" });
export const customerApi = createClient({ storageKey: "acme-app" });Different keys mean different storage entries and different channels, so they're fully independent.
api.onAuthStateChange((state) => {
if (!state.isAuthenticated) {
queryClient.clear();
router.push("/login");
}
});This fires whether the logout happened here or in another tab — the handler doesn't need to care.
A dedicated notice:
let wasAuthed = false;
api.onAuthStateChange((s) => {
if (wasAuthed && !s.isAuthenticated) {
toast.info("You were signed out in another tab.");
}
wasAuthed = s.isAuthenticated;
});Open your app in two tabs:
- Log in on tab A → tab B's
onAuthStateChangefires withisAuthenticated: true. - Log out on tab A → tab B clears immediately and
onAuthFailurefires. - With
storage: "local", force a refresh on tab A → tab B picks up the newexpiresAt.
Programmatically:
const channel = new BroadcastChannel("apiclient.auth");
channel.onmessage = (e) => console.log("tab message:", e.data);You'll see the login, refreshed and logout traffic — and confirm for yourself that no token ever appears in it.
Getting started
Requests
Authentication
Advanced
- Web Worker Isolation
- Multi-Tab Sync
- WebSockets and Socket.io
- Plugins
- Logging and Observability
- Security Model
Reference
Guides