cf.completefrontend
My progress
SYSTEM DESIGN · #71

Design state sync across browser tabs

People keep the same web app open in five tabs at once. The hard parts are deciding which state must cross the tab boundary and how fast, picking a transport between tabs, electing one tab to own the WebSocket and the timers, refreshing a token exactly once for everyone, keeping tabs consistent when messages arrive late or never, and not breaking when two tabs run different versions of the app.

The question

Our users open the app in many tabs: one for the inbox, one for a board, one for settings. Today each tab acts alone. People sign out in one tab and the others keep working. The cart badge shows 2 items in one tab and 3 in another. Each tab opens its own WebSocket, and we see the same user refresh their token four times in one second.

Design how state stays in sync across the tabs of one browser. Tell me what must sync and how fast, which browser APIs carry the messages, how one tab takes charge of the socket and background work, how token refresh and logout work across tabs, and what happens when a tab sleeps, crashes, or runs an older version of the app.

Real-life examples: Gmail open in one tab for the inbox and another for a draft, Slack in three tabs that must not open three sockets, Figma files in many tabs that share one signed-in session, Jira boards where a status change in one tab shows in the others, A banking site where signing out in one tab signs out every tab, A shopping site where the cart badge matches in every tab

Requirements: what crosses the tab boundary

A tab here means one open page of our app: one document with its own JavaScript heap, its own React tree, and its own memory. Two tabs of the same site share some things on disk (cookies, localStorage, IndexedDB, the HTTP cache) but they share nothing in memory. A variable you set in tab A does not exist in tab B. Every bug in the question comes from that gap.

People do open many tabs. Power users of Gmail, Jira or Notion often keep 3 to 10 tabs of the same app open all day (an estimate, not a published number). On a phone it is usually one or two, but the browser keeps old tabs in the background, often frozen, and wakes them hours later.

The cross-tab-sync lesson teaches the browser APIs one by one: the storage event, BroadcastChannel, SharedWorker, and Web Locks basics. This guide designs the whole system of a multi-tab app on top of them. Nearby parts have their own guides, linked at the end: auth flow (sessions and refresh in one tab), shopping cart, dark mode theming (theme storage and first paint), rate-limited queue (one tab owning a request queue), offline sync engine (the outbox), and Slack and Gmail (the products that live in many tabs).

Questions to ask first

QuestionAnswer this design assumesWhy it changes the design
How do we keep the session: cookie or token in JavaScript?An HttpOnly session cookie for the web app, plus a short-lived access token in memory for the APIA cookie is shared by all tabs for free. A token in memory is not, so tabs must share it or each must refresh.
Is there a live connection to the server?Yes, one WebSocket per user for new mail, mentions, and presenceFive tabs mean five sockets unless one tab owns it and relays to the others.
Can the user edit the same thing in two tabs?Yes: a draft, a ticket description, a settings formWe must detect it and warn, or one tab silently overwrites the other.
Which browsers and devices?Last two versions of Chrome, Edge, Firefox, Safari and Samsung Internet, on desktop and mobileSome APIs are missing on some of them (Samsung Internet has no SharedWorker), so the design needs a fallback.
Do we ship often?Several deploys a dayAn old tab and a new tab will talk to each other, so messages need a version.
Shared or public computers?Yes, for the banking variantLogout must wipe caches in every tab, not only hide them.

Functional requirements

  • Logout everywhere: signing out in one tab signs out every tab of the app within about 1 second, and their screens stop showing private data.
  • One refresh: when the access token expires, exactly one tab refreshes it and every tab uses the new token.
  • Shared data stays fresh: a cart change, a read message, or a renamed ticket in one tab shows in the others without a reload.
  • Settings follow: theme, language, density and notification settings changed in one tab apply in all of them.
  • Drafts are safe: if the same draft is open in two tabs, the user is warned before one tab overwrites the other.
  • One owner for background work: only one tab holds the WebSocket, runs polling, and runs background sync. If it closes, another takes over.
  • Each tab keeps its own place: route, scroll position, open panels and selection are per tab and never synced.

Non-functional requirements

NeedTargetHow we will check it
Logout reaches other awake tabsUnder 1 s, usually under 50 msPlaywright test with three pages
Shared data reaches other tabsUnder 200 ms after the change lands in the first tabTimestamps in the message, logged in development
Leader hand-over after the leader tab closesUnder 1 s with Web Locks; under 10 s with the fallbackClose the leader page in a test and time the new socket
Sockets per user per browserExactly 1 while any tab is openServer metric: connections per user and device
Token refresh calls per expiryExactly 1Server metric: refresh calls per session per minute
Main-thread cost of syncUnder 1 ms per message; no localStorage writes in hot pathsPerformance panel, long tasks
Old and new app versionsNever crash on an unknown messageContract test with an old and a new message

Out of scope

  • Sync across devices (phone and laptop). That goes through the server and belongs to the offline sync engine, Slack and Gmail guides. Here we sync tabs of one browser profile.
  • Real-time co-editing of one document by two people. That needs CRDTs or operational transforms (see the Google Docs guide). Two tabs of one user are a much smaller problem.
  • Sync with other origins or iframes from other sites. We stay same-origin on purpose (see the security chapter).
Three tabs of the same app side by side. Tab A shows the inbox with a sync badge saying leader, a list with 3 unread, and a cart badge of 2. Tab B shows draft 42 with a red banner saying it is open in tab A, a read-only editor, and two buttons: switch tab and edit here. Tab C shows settings with theme dark, a banner saying a new version is available with a reload button, and the same cart badge of 2.A: inboxsync: leaderInbox3 unreadroute: /inboxcart 2darkB: draft 42sync: followeropen in tab Aread-onlyswitchedit hereC: settingssync: followernew versiontheme: darkReload buttoncart 2darkowns the socketdraft lock held by Aruns an older build
Each tab keeps its own route and screen, but the cart badge, theme and sign-in state match everywhere. The draft banner and the reload banner are the two places where sync shows itself to the user.

Check your understanding · Exercise 1

Which answers from the interviewer change this design the most? Select all that apply.

What makes this hard

Syncing two tabs sounds like one line of code: post a message, listen for it. The difficulty is everything around that line. Five traits drive every decision in this guide.

  1. Tabs are peers with no boss. The browser does not say which tab is the main one, and any tab can open, close or crash first. If one tab is to own the socket, the tabs must agree among themselves, and the agreement must survive a crash.
  2. Tabs sleep. Chrome throttles a hidden tab's repeating timers to about once a minute after 5 minutes, and a tab can be frozen (no JavaScript runs) or discarded (thrown away and reloaded when the user returns). A frozen tab gets queued messages late, in a burst; a discarded one never gets them.
  3. Messages have no order across senders and no history. Two tabs can change the cart at the same moment, and a third may see the changes in either order. A tab that opens after a change never sees its message.
  4. Tabs run different code. After a 14:00 deploy, a tab opened at 13:00 runs the old bundle and a tab opened at 14:05 the new one. They share storage and channels, so old code reads what new code wrote.
  5. The stakes differ by kind of state. A theme 2 seconds late is fine; a logout that never reaches a tab on a shared computer is a security bug; a draft overwritten by a stale tab loses work.

Here is the bug report that started this design. A user has the app in three tabs, and at 10:00:00 the access token expires. Each tab gets a 401 and calls POST /auth/refresh with the same refresh token cookie, within a few milliseconds of each other. The server rotates refresh tokens: the first call gets a new pair and makes the old refresh token invalid.

The second and third calls arrive 30 ms later with the old token. The server sees a used token come back, treats it as theft, and revokes the whole session, so the user is signed out of every tab. Each tab did the right thing alone; the bug is the missing coordination. That is the theme of the guide: decide who does what once, and how the others find out.

Think first

Why not just reload the other tabs whenever something important changes, for example by setting a flag in localStorage that every tab polls?

Decision 1: what must sync, and how strongly

The first decision is not an API. It is a list. Before choosing how tabs talk, sort every piece of state in the app by what goes wrong if two tabs disagree about it. The naive answers are both wrong, and it helps to see why.

Decision How much state should tabs share?

Mirror the whole store

Every change to the global store (Redux, Zustand) is posted to the other tabs, which apply it.

Some libraries do this with one line of setup.

Pros

  • Simple to explain.
  • No list of what syncs to maintain.

Cons

  • Syncs things that must stay per tab: the route, the open modal, the selected row. Open a modal in one tab and it opens in all five.
  • Every keystroke in a search box becomes a message to every tab.
  • Two tabs editing at once overwrite each other, with no rule for who wins.

Sync nothing, refetch on focus

Each tab is alone. When a tab becomes visible it refetches its data from the server.

Pros

  • No cross-tab code at all.
  • The server is always the truth.

Cons

  • Logout does not reach other tabs until someone focuses them, and a tab on a shared screen keeps showing private mail.
  • Every tab still opens its own socket and refreshes its own token.
  • Drafts still overwrite each other.

A policy per kind of state

Sort state into a few classes. Each class gets its own rule: how fast, who may write, and what wins on conflict.

Pros

  • Strong guarantees where they matter (auth), cheap ones where they do not (theme).
  • UI state stays per tab by design, not by accident.

Cons

  • Someone must classify each new piece of state. We make it a field in the code, so review catches it.

What we pick: A policy per kind of state. It is a little more work up front, but it is the only option that makes logout instant without making a modal open in five tabs. The table below is the policy, and the rest of the guide implements it.

ClassExamplesHow fastWho writesOn conflict
Auth and sessionsigned in or out, access token, current user id, permissionsImmediately, and must reach every tabOnly the tab that signed in, out, or refreshedLogout always wins
Settingstheme, language, density, notification switchesEventually, within a secondAny tabHighest version wins
Server data cachecart, unread counts, ticket fields, message listWithin 200 ms for visible tabs; on next focus for hidden onesThe server; tabs send changes thereServer version wins
Drafts and formsan email draft, a ticket being edited, a settings formNever merged silentlyOne tab at a timeDetect and warn the user
UI stateroute, scroll, open panels, selection, search box textNever syncedEach tab for itselfNot a conflict

The same query in two tabs: invalidate or share?

Both tabs show the cart. Tab A adds a mug, and the server answers 200 with the new cart at version 18. What should tab B do? It can get an invalidation ("cart is stale") and refetch, or get the data (the new cart) and write it into its own cache.

Sending only invalidations is simplest and always correct, but with 6 tabs open one click causes 5 extra GET /cart calls. Sending data saves those calls, but the message can be large, and B must be sure the data is newer than what it has.

This design does both, by size. A small response (a cart, a ticket, a settings object: a few KB) is broadcast as { key, version, data }, and the other tabs write it straight into their query cache (in TanStack Query that is setQueryData). A large response or a list (an inbox page, search results) is broadcast as { key, version } only, and receivers mark the query stale.

A hidden tab never refetches right away: it marks the query stale and refetches when visibilitychange says it is visible again, so five hidden tabs cost zero requests. Query caches and invalidation in general are covered in the server-state-libraries and cache-invalidation lessons.

Drafts: detect and warn, never merge silently

The user opens the same draft in two tabs, types in tab A, then later types in tab B, which still shows the old text. If B autosaves, A's paragraph is gone. Merging text from two tabs of one person is rarely worth a CRDT. A simpler rule works: one tab edits a draft at a time, and the others know it.

The tab that starts editing draft 42 asks for a Web Lock named edit:draft:42 with ifAvailable: true and holds it while the editor is open. A second tab asking for the same lock gets null back at once, so it shows a banner: "This draft is open in another tab". The banner has two buttons. Switch to that tab posts draft/focus-request, and the other tab shows a notice, since a page cannot bring another tab to the front by itself.

Edit here instead takes the lock with steal: true. The old tab's lock request then rejects, so it switches its editor to read-only and reloads the latest saved text. Because the browser releases a lock when its tab closes or crashes, no stale "someone is editing" flag is left behind. As a second guard, every save carries the draft's version, and the server answers 409 Conflict if the version is old.

Sequence for opening draft 42 in tab B while tab A edits it. The user opens draft 42 in tab B. Tab B requests the lock edit:draft:42 with ifAvailable and gets null, because tab A holds it, so it shows a banner. The user clicks Edit here. Tab B requests the lock with steal true. Tab A's lock promise rejects, so tab A turns read-only. Tab B saves with PUT and base version 7. The failure path: the server answers 409 Conflict because version 8 exists, and tab B reloads the newer text.UserTab BLocksTab AServeropen draft 42ifAvailablenull: heldEdit heresteal: truelock lostPUT, base v7409: v8 exists
The lock answers "is someone editing this?" at once and cleans itself up when a tab closes. The version on the save is the second guard, for the case the lock cannot see: an edit saved from another device.

Check your understanding · Exercise 2

A teammate adds a "compact list" switch, a per-ticket comment box, and an "unread count" badge. Which of these classifications are right? Select all that apply.

Decision 2: how tabs talk to each other

Now we pick the pipe. The cross-tab-sync lesson explains each API on its own. Here we weigh them as the backbone of a whole app, against our policy: auth must reach every tab fast, server data needs versions and a way to catch up, and one tab will relay socket events to the rest.

Decision Which channel carries sync messages between tabs?

The storage event

A tab writes localStorage.setItem("sync", json).

Every other same-origin tab gets a storage event with the key, old value and new value. The writing tab does not get it.

Pros

  • Works in every browser, including very old ones.
  • The last value stays on disk, so a tab that opens later can read it.

Cons

  • Strings only: every message is JSON.stringify and JSON.parse.
  • localStorage is synchronous: each write blocks the main thread on disk, and the whole origin shares about 5 MiB.
  • Writing the same value again fires no event, so repeated messages need a unique field.
  • It is shared state pretending to be a message bus: two tabs writing the key at once overwrite each other.

BroadcastChannel

Each tab opens new BroadcastChannel("app") and calls postMessage(obj).

Every other BroadcastChannel object with that name in the same origin (in tabs, iframes or workers) gets a message event. The sending object does not.

Pros

  • Structured clone: objects, Map, Date, ArrayBuffer, no manual JSON.
  • Asynchronous, no disk write, cheap.
  • Baseline in every major browser since Safari 15.4 (March 2022).

Cons

  • Fire and forget: nothing is stored, so a tab that is discarded or not yet open misses the message, and a frozen tab gets it late.
  • No ordering guarantee between different senders.
  • No reply or delivery receipt built in.

SharedWorker as a hub

Every tab connects to one SharedWorker by URL and name.

The worker keeps the state and the WebSocket and talks to each tab over its own MessagePort.

Pros

  • One instance by construction: no election needed for the socket.
  • State lives in one place, so ordering is natural: the worker sees every change in sequence.

Cons

  • Support is newer: Safari brought it back in 16, Chrome for Android only shipped it in version 148 (2026), and Samsung Internet still lacks it, so a fallback is required anyway.
  • No localStorage or DOM in the worker, a separate bundle, and debugging through chrome://inspect.
  • After a deploy, old tabs keep the old worker and new tabs start a new one if the script URL changed.

Service worker as a hub

Tabs postMessage to the service worker, which forwards to clients.matchAll({ type: "window" }).

Pros

  • Already present if the app has offline support (see the service-workers lesson).

Cons

  • The browser stops an idle service worker after about 30 seconds in Chrome, so it cannot own a WebSocket or hold state in memory.
  • Every message makes two hops, and on a first visit there is no controlling worker yet.

What we pick: BroadcastChannel for the news, storage for the truth. Every message is a small notice ("cart is now version 18, here it is") and the current value of anything that must survive also goes to storage: localStorage for a few tiny keys (the auth epoch and settings), IndexedDB for cached server data. One tab, elected with Web Locks, owns the socket. A SharedWorker would remove the election, but browsers without it would still need one, so we keep a single path that works everywhere.

Say the core idea out loud in the interview: messages are hints, storage is truth. If every message were lost, the app would still be correct after the next read from storage, only slower. That rule tames sleeping tabs, crashed senders and messages in the wrong order, as the consistency chapter shows.

Pick a tab, then act in it. Put a tab to sleep and act in another to see what it misses.

Three tabs of one app. Tab A: signed in, light, cart 1, draft: closed. Tab B: signed in, light, cart 1, draft: closed. Tab C: signed in, light, cart 1, draft: closed. Transport BroadcastChannel, catch-up on wake on. Truth: signed in, light theme, cart 1. 0 deliveries, 0 localStorage writes. Every awake tab matches.Tab A: actingsigned intheme: lightcart: 1draft: closedTab Bsigned intheme: lightcart: 1draft: closedTab Csigned intheme: lightcart: 1draft: closedtruth: signed in, light, cart 1deliveries 0, localStorage writes 0, missed now 0
A red cell is a value that disagrees with the truth. Sleep a tab, act in another, then wake it with catch-up off and on. The SharedWorker hub keeps state in one place, so a waking tab asks it; the other two need storage to catch up.

The whole bus is one small module. It wraps BroadcastChannel, adds an envelope with a sender id, and drops messages from its own tab id. Those arrive only if the same tab opens a second channel object, for example in a worker, but the check costs nothing.

ts
const TAB_ID = crypto.randomUUID();
const channel = new BroadcastChannel("app-sync");

export function publish(msg: SyncMessage) {
  const envelope: Envelope = { v: PROTOCOL, from: TAB_ID, at: Date.now(), build: BUILD_ID, msg };
  channel.postMessage(envelope);
}

export function subscribe(handler: (msg: SyncMessage) => void) {
  const onMessage = (e: MessageEvent) => {
    const env = parseEnvelope(e.data); // null if unknown version or shape
    if (!env || env.from === TAB_ID) return;
    handler(env.msg);
  };
  channel.addEventListener("message", onMessage);
  return () => channel.removeEventListener("message", onMessage);
}

// A page restored from the bfcache missed every message: catch up from storage.
addEventListener("pageshow", (e) => { if (e.persisted) catchUpFromStorage(); });
tab-bus.ts: one channel per app, typed messages, an envelope on each.

Check your understanding · Exercise 3

Which statements about the transports are true? Select all that apply.

Decision 3: one tab in charge of the socket and timers

Some work must happen once per browser, not once per tab: the WebSocket, polling for new mail every 60 seconds, the background sync of the outbox (see the offline sync engine guide), the request queue (see the rate-limited queue guide), and the timer that refreshes the token early. With 6 tabs and no coordination, a user holds 6 sockets. At 200,000 users online that is over a million connections where 200,000 would do, and the server sends every event 6 times.

So the tabs elect a leader: one tab that does the shared work and relays what it learns to the others (the followers). The election must give exactly one leader at a time, notice fast when the leader is gone, and never leave two leaders running for long (split brain).

Decision How do the tabs pick one leader?

Web Locks

Every tab calls navigator.locks.request("app:leader", callback) and waits in the queue.

The browser grants the lock to one tab. Its callback returns a promise that never settles, so it holds the lock while open.

When that tab closes, crashes, or navigates away, the browser releases the lock and grants it to the next tab in the queue.

Pros

  • The browser is the referee: no clocks, no polling, no race.
  • Hand-over in milliseconds when a tab closes, even on a crash.
  • Baseline since March 2022 (Safari 15.4); works in workers too.

Cons

  • HTTPS only (a secure context).
  • A leader that is suspended but not closed still holds the lock, so followers need a liveness check.
  • No built-in priority: the order of the queue decides.

Heartbeat in localStorage

The leader writes { id, at: Date.now() } to a key every 2 seconds.

Any tab that sees the timestamp older than 6 seconds writes its own id and becomes leader.

Pros

  • Works without Web Locks, for example in an old embedded webview.

Cons

  • localStorage has no compare-and-set: two tabs can read "expired" at the same moment and both write. Two leaders, two sockets.
  • Background tabs have throttled timers (once a minute in Chrome after 5 minutes hidden), so a healthy leader in the background looks dead and gets replaced, then wakes and still thinks it leads.
  • Hand-over waits for the timeout: seconds, not milliseconds.

Let the server decide

Every tab connects. The server keeps one connection per browser and closes the others with a code that means "another tab has it".

Pros

  • No client election code.

Cons

  • Every tab still opens and closes a socket on each load.
  • The tabs still need to relay events among themselves, so they need a channel anyway.
  • The server needs a stable browser id, which is another cookie to manage.

What we pick: Web Locks, plus a heartbeat on the channel as a liveness check. The lock gives exactly one owner and instant hand-over on close or crash. The heartbeat covers the one case the lock cannot see: a leader that still holds the lock but is not running. The localStorage election stays only as a fallback for browsers without navigator.locks, with its split-brain risk accepted and logged.

ts
let duties: AbortController | null = null;

export function campaign() {
  setRole("follower");
  navigator.locks
    .request("app:leader", async () => {
      duties = new AbortController();
      setRole("leader");
      startLeaderDuties(duties.signal); // socket, polling, heartbeat replies, token timer
      await new Promise<void>((resolve) =>
        duties!.signal.addEventListener("abort", () => resolve()),
      );
    })
    .catch((err) => {
      if (err.name !== "AbortError") throw err; // AbortError: our lock was stolen
    })
    .finally(() => {
      duties?.abort(); // stop the socket and timers, whatever the reason
      duties = null;
      campaign(); // stand in the queue again as a follower
    });
}
leader.ts: queue for the lock; lead until the lock is released or stolen; then queue again.

Every way a leader can go away

What happens to the leader tabLockWhat the design does
The user closes it, or it navigates to another siteReleased at onceThe next tab in the queue is granted the lock, opens the socket, and catches up from storage.
The renderer crashesReleased when the browser cleans up the dead pageSame as a close.
Chrome freezes it in the backgroundStill heldChrome's Energy Saver does not freeze a tab that holds a Web Lock another tab is waiting for (Chrome 133 and later), so this is rare in Chrome. Other browsers and mobile systems suspend background pages by their own rules.
The phone suspends the whole browserHeld, but nothing runsAll tabs are suspended together. On resume, the leader's socket reconnects and every tab catches up from storage.
Chrome discards it to save memoryReleased, the page is goneSame as a close. When the user returns, the page reloads and document.wasDiscarded is true.
The user navigates within the tab, and the page could enter the back/forward cache (bfcache)Released: Chrome does not cache a page that holds a Web Lock, so leaving it ends the pageSame as a close. If a browser does restore it from the bfcache, pageshow with persisted: true makes the tab campaign again and catch up.

For the suspended-but-holding case we add a heartbeat. A visible follower posts leader/ping on the channel every 5 seconds, and the leader answers leader/heartbeat from its message handler. The ping comes from the visible tab on purpose: browsers throttle timers in hidden tabs, not message handlers, so a healthy leader in the background still answers at once. A frozen leader does not answer at all.

After 15 seconds without an answer, the visible follower calls navigator.locks.request("app:leader", { steal: true }, ...). Hidden tabs never steal, because their throttled timers make them the most likely to be wrong about time. When the old leader wakes, its lock promise has already rejected with AbortError, so the finally above shuts its socket down among the first tasks it runs. Two sockets can overlap only for that moment.

State machine of a tab's role. A follower waits in the lock queue. When the lock is granted it becomes leader, with the socket and timers. A leader that is frozen or put to sleep becomes suspended: it still holds the lock but runs nothing. If it wakes before anyone steals, it is leader again. If a visible follower hears no heartbeat for 15 seconds, it steals the lock, and the suspended tab becomes a follower when it wakes and sees AbortError. A leader that closes, crashes, or is discarded is gone, and the browser releases its lock at once.Followerqueued for lockLeadersocket, timersSuspendedholds lock, idleGonelock releasedlock grantedfrozenwakes, not stolenstolen after 15 sclose, crash, discardsteps down onAbortError
Only two edges need code of our own: the steal after 15 silent seconds, and stepping down on AbortError. The browser drives every other edge through the lock.

Tab A got the lock app:leader first. B and C wait in the queue.

Three tabs electing a leader. Tab A: leader. Tab B: follower. Tab C: follower. Web Locks, time 0 s. live sockets: 1. Last hand-over: none yet. Refresh calls: 0.Tab Aleadersocket openTab Bfollowerno socketTab Cfollowerno socketlive sockets: 1, time 0 s, hand-over none yetrefresh calls: 0
Close the leader under each election, then freeze it and press Wait a few times. Watch the socket count: Web Locks hands over at once on close and never shows two live leaders; the heartbeat waits for a timeout and can elect two.

Think first

Should the leader move to whichever tab the user is looking at?

Check your understanding · Exercise 4

Which statements about the Web Locks election are true? Select all that apply.

Decision 4: auth across tabs: logout everywhere, refresh once

Auth has the strictest policy. How sessions, cookies and refresh tokens work in one tab is in the auth flow guide and the auth-tokens lesson; here we add only what changes with many tabs. This design keeps the refresh token in an HttpOnly cookie (all tabs share it, no script can read it) and the access token in memory (each tab has its own copy, valid about 10 minutes).

Logout everywhere

A user signs out in tab A. Tabs B and C still have the inbox on screen, an access token in memory, and cached messages in IndexedDB. On a shared library computer, the next person walks up to tab B. So logout must do three things in every tab, even if the network is down: stop showing private data, drop every secret, and wipe caches.

  1. Tab A bumps an auth epoch: a counter in localStorage (auth:epoch = 8, with state: "signed-out"). Writing it also fires a storage event in every other tab: a second, free delivery path for the one message that must not be lost.
  2. Tab A posts { type: "auth/signed-out", epoch: 8 } on the channel.
  3. Every tab that sees an epoch higher than its own runs the same signOutLocally(): forget the access token, clear the query cache, delete the user's IndexedDB databases, close the socket if it leads, and location.replace("/signin") so Back does not show the inbox.
  4. Tab A calls POST /auth/logout to revoke the session on the server. If that call fails, the local sign-out has still happened everywhere. The call is retried with the outbox, and the session cookie is cleared by the response or expires.

Why an epoch and not just a message? A tab that was frozen during the logout gets no message. When it wakes, it checks auth:epoch in localStorage on visibilitychange, pageshow and before every API call, sees 8 against its own 7, and signs out. The same epoch handles sign-in: if a different user signs in at epoch 9, every old tab must reload, not re-render, because its memory holds the previous user's data.

Sequence for signing out. The user clicks Sign out in tab A. Tab A writes auth epoch 8 to localStorage, and posts auth signed-out on the channel to tab B. localStorage also fires a storage event in tab B, a second path. Tab A then calls POST /logout on the server, which fails because the network is down. Tab A is signed out anyway and retries the revoke later. Tab B wipes its caches and replaces the page with the sign-in page.UserTab AStorageTab BServerSign outepoch 8signed-outstorage eventPOST /logoutofflineA: signed out anywayretry revoke laterB: wipe cachesreplace to /signin
Local sign-out never waits for the server. The epoch on disk is what a tab that slept through all of this reads on wake, and the storage event is a free second delivery for the one message that must not be lost.

Refresh exactly once

Back to the bug from the requirements chapter. Many identity providers rotate refresh tokens for browser apps (Okta does it by default for single-page apps): each refresh returns a new refresh token and invalidates the old one. If an old one is used again, the server assumes it was stolen and revokes the whole token family. Auth0 and Okta both document this reuse detection.

Both also offer a window in which the previous token still works: Okta's grace period (30 seconds by default, 0 to 60) and Auth0's rotation overlap period (off by default). Such a window can hide the bug, but it is a server setting the client does not control, so the client must not depend on it.

Decision Who refreshes the access token when it expires?

Each tab for itself

Every tab's API client refreshes when it gets a 401.

Pros

  • No coordination.

Cons

  • With 3 tabs, 3 calls with the same refresh token. With rotation and reuse detection, that can revoke the session for everyone.

The leader refreshes for everyone

Followers ask the leader for a token and wait for the reply.

Pros

  • One place owns the timer that refreshes early, before expiry.

Cons

  • A follower with a 401 now depends on the leader being awake. If the leader is suspended, every follower stalls.

Single flight with a lock

Any tab that needs a fresh token takes the auth:refresh lock.

Inside the lock it checks the shared token first: if another tab refreshed in the last few seconds, it uses that one.

Only if the token is still old does it call POST /auth/refresh, then it broadcasts the new access token and its expiry.

Pros

  • Exactly one refresh even if every tab hits a 401 at once.
  • No dependency on which tab leads.

Cons

  • A few lines of careful code; the check inside the lock is easy to forget.

What we pick: Single flight with a lock, and the leader also refreshes 60 seconds before expiry so most tabs never see a 401. The lock makes refresh correct; the leader's early refresh makes it invisible.

ts
export async function getFreshToken(staleToken: string): Promise<string> {
  return navigator.locks.request("auth:refresh", async () => {
    // Another tab may have refreshed while we waited for the lock.
    if (shared.token && shared.token !== staleToken && shared.expiresAt > Date.now() + 5_000) {
      return shared.token;
    }
    const res = await fetch("/auth/refresh", { method: "POST", credentials: "include" });
    if (res.status === 401) {
      signOutEverywhere("refresh-rejected"); // the session is really over
      throw new AuthError("signed-out");
    }
    const { accessToken, expiresAt } = await res.json();
    shared.token = accessToken;
    shared.expiresAt = expiresAt;
    publish({ type: "auth/token", accessToken, expiresAt, epoch: authEpoch() });
    return accessToken;
  });
}
One refresh across all tabs. The re-check inside the lock is the important line.

The lock and the re-check do different jobs. The lock stops two tabs from sending the same refresh token at the same time, which is what reuse detection punishes: when the second tab gets the lock, the first call's response has already put the rotated token in the cookie. The re-check stops the second tab from refreshing at all, which keeps the server at one refresh per expiry.

Each tab keeps shared up to date from the auth/token messages. A tab that missed the message (it was frozen) has the old token, gets a 401, takes the lock, and the re-check fails, so it refreshes itself: a second refresh, but minutes later with the current cookie, which is a normal rotation and not a reuse.

Sequence for refreshing a token in two tabs. Tab A requests the auth:refresh lock and gets it. Tab B requests the same lock and waits. Tab A calls POST /auth/refresh on the server, which returns a new access token valid for 10 minutes. Tab A broadcasts the new token to tab B and releases the lock. The lock is granted to tab B, which re-checks, finds a fresh token, and makes no call. Failure path: if the refresh returns 401, the session is over and every tab is signed out.Tab ATab BLockServerauth:refreshwaitsPOST /refreshtoken, 10 minauth/tokengrantedB re-checks: fresh,no second callif refresh says 401:sign out every tab
The lock keeps the two calls apart, so tab B never sends the old refresh token while tab A's call is in flight, which is what reuse detection punishes. The re-check inside the lock then finds the token already fresh, so the server sees one refresh.

Check your understanding · Exercise 5

Which parts of this auth design are needed for correctness, not only speed? Select all that apply.

Decision 5: order, versions, and catching up

Messages between tabs arrive late, out of order, twice, or never. Here is a concrete case. The cart has a mug with quantity 1. In tab A the user sets it to 2, and the server saves it as cart version 17, but the response to tab A is slow.

A moment later, in tab B, the user sets it to 3; the server saves version 18 and B's response comes back fast. B broadcasts version 18, then A's slow response lands and A broadcasts version 17. If tab C applies messages in arrival order, it ends at quantity 2 while the server says 3, and nothing corrects it until C refetches.

Decision How does a receiving tab decide whether a message is newer than what it has?

Arrival order

Apply each message as it comes.

Pros

  • No extra fields.

Cons

  • Wrong whenever two tabs write close together, as in the cart example.

Timestamp, last writer wins

Each message carries Date.now(); the newer timestamp wins.

Pros

  • Tabs of one browser share one clock, so it is better than across devices.

Cons

  • The order in which writes reached the server is what matters, and that can differ from the order the tabs sent them.
  • Two writes in the same millisecond tie.
  • The user can change the system clock between writes.

Version per key

Server data carries the server's version (a counter or updatedAt sequence that the server increases on each write).

Local-only state (theme, layout) carries a per-key counter: a writer takes the highest version it has seen plus 1, and the tab id breaks ties.

Pros

  • The receiver keeps whatever has the higher version, in any arrival order.
  • Applying the same message twice is harmless.

Cons

  • The server must send a version with every response and event. Most APIs can (an ETag, a row version, an event sequence number).

Leader as the single writer

Followers send changes to the leader; only the leader writes to storage and broadcasts.

Pros

  • One writer means one order.

Cons

  • Every write now waits on another tab, and stalls if the leader is suspended.
  • The server already orders writes; a second orderer in the client adds latency without adding truth.

What we pick: Version per key. Server data uses the server's version, so the server, which really orders the writes, decides. Local settings use a counter with the tab id as tie breaker. The leader is the single writer only for things it alone owns: the socket cursor and the shared request queue.

ts
type Versioned<T> = { key: string; version: number; writer: string; data: T };

function isNewer(incoming: Versioned<unknown>, current?: Versioned<unknown>) {
  if (!current) return true;
  if (incoming.version !== current.version) return incoming.version > current.version;
  return incoming.writer > current.writer; // same version: tab id breaks the tie
}

export function applyRemote<T>(msg: Versioned<T>) {
  const current = versions.get(msg.key);
  if (!isNewer(msg, current)) return; // late or duplicate: ignore
  versions.set(msg.key, msg);
  queryClient.setQueryData([msg.key], msg.data);
}
Apply is idempotent and order-free: an older or repeated message changes nothing.

A sleeping tab catches up from storage, not from messages

A tab frozen for 2 hours missed hundreds of messages. Chrome runs a frozen page's queued tasks when it resumes, so some messages may arrive late in a burst. A discarded page gets nothing, and neither does one restored from the bfcache: in Chrome, a cached page that receives a channel message is evicted from the cache instead. Replaying messages is the wrong tool anyway: we do not store them, and many would be stale.

Instead, every tab has one function, catchUp(). It runs on visibilitychange to visible, on pageshow with persisted: true, on the resume event (Chrome's Page Lifecycle API), and on online. It does four cheap reads and decides from versions:

  1. Read auth:epoch. If it moved, sign out or reload, and stop here.
  2. Read the settings record from localStorage (one key, under 1 KB). Apply it if its version is higher.
  3. Read the version table from IndexedDB (key to version). Mark every cached query with a lower version as stale; the visible ones refetch.
  4. Read app:build (the newest deployed build id that any tab has seen). If it is newer than this tab's build, show the "new version" prompt (next chapter).

The burst of old messages that may arrive after a freeze is then harmless: each one is older than what catchUp() already applied, so isNewer drops it. This is the payoff of "messages are hints, storage is truth".

Timeline from 10:00 to 12:00. Tab A, awake, writes cart version 18, theme version 5, and cart version 19. Each write is saved to storage. Tab C is frozen the whole time and receives none of the messages. At 12:00 tab C wakes and runs catchUp, which reads the versions from storage and applies cart version 19 and theme version 5. Old messages that arrive in a burst after the wake are older than what it has, so they are ignored.Tab AstorageTab Ccart v18theme v5cart v19v18v5v19frozen: no messages arrivecatchUp()late: ignored10:0011:0012:00
Tab C never sees the three messages, and it does not need to. catchUp() reads a few small records and compares versions, so a burst of late messages afterwards changes nothing.

Check your understanding · Exercise 6

Tab C receives cart version 18, then cart version 17, then version 18 again. What should it show, and why? Pick one.

Interface: the message protocol, and surviving a deploy

The tabs now share a channel, storage, and a leader. The interface of this system is the set of messages on the channel and the keys in storage. Treat it like a public API: typed, versioned, and documented, because the two ends of it are often different builds of the app.

ts
export const PROTOCOL = 3; // bump only for breaking changes

export type Envelope = { v: number; from: string; at: number; build: string; msg: SyncMessage };

export type SyncMessage =
  | { type: "auth/signed-out"; epoch: number; reason: string }
  | { type: "auth/signed-in"; epoch: number; userId: string }
  | { type: "auth/token"; epoch: number; accessToken: string; expiresAt: number }
  | { type: "settings/changed"; version: number; writer: string; settings: Settings }
  | { type: "cache/update"; key: string; version: number; data: unknown }
  | { type: "cache/invalidate"; keys: string[]; version: number }
  | { type: "draft/editing"; draftId: string; tabId: string }
  | { type: "draft/focus-request"; draftId: string }
  | { type: "activity"; at: number }
  | { type: "leader/ping"; tabId: string }
  | { type: "leader/heartbeat"; tabId: string; socket: "open" | "connecting" | "closed" }
  | { type: "leader/request"; id: string; action: "send-ws"; payload: unknown }
  | { type: "app/hello"; build: string; protocol: number };
sync-protocol.ts: the envelope and every message type. Adding a type is a minor change; changing a shape is a major one.
MessageSent byAlso written to storage?Receivers do
auth/signed-outAny tabYes: auth:epoch in localStorageSign out locally, wipe caches, go to sign-in
auth/tokenThe tab that refreshedNo: tokens never go to diskReplace the in-memory access token
settings/changedAny tabYes: settings in localStorageApply if the version is higher
cache/update and cache/invalidateThe writing tab, or the leader relaying a socket eventYes: data and version in IndexedDBSet or mark stale in the query cache
leader/ping and leader/heartbeatA visible follower every 5 s; the leader answersNoThe follower resets its 15 s steal timer
leader/requestA followerNoOnly the leader acts: sends on the socket and replies
app/helloEach tab on startYes: app:build if newerCompare builds and protocols

The leader relays server events. The socket delivers { "type": "cart.updated", "cartId": "c1", "version": 19, "cart": {...} }. The leader applies it to its own cache, writes the version to IndexedDB, and publishes cache/update with key cart.

Followers that want to send something on the socket (a typing indicator, a presence ping) post leader/request, and the leader sends it. Keep this path for small, loss-tolerant messages; writes that must not be lost go over HTTP from the tab that made them, so they never depend on another tab.

Old tab, new tab

At 14:00 we deploy build b-912, which renames cart.items to cart.lines and bumps PROTOCOL from 3 to 4. A tab opened at 13:00 still runs b-911. Without care, the new tab broadcasts a cart with lines, the old tab reads cart.items.length, and throws. Four rules prevent that:

  1. Tolerant reader: a receiver validates every message (a small schema check) and ignores unknown types and unknown fields. It never throws on input from another tab.
  2. Same major or nothing: a message with a different v is not parsed at all. It is only used as a signal: some tab runs another protocol.
  3. Expand, then contract: for one release, new code writes both items and lines and reads either. The release after that stops writing items. Most deploys then need no protocol bump.
  4. Old tabs ask to reload: a tab that sees a higher protocol or a newer app:build shows a banner, "A new version is available", with a Reload button. It does not reload by itself while it holds a draft lock or has unsaved input; it reloads on the user's click or on the next navigation.

Storage needs the same care as messages, and IndexedDB enforces it for you. When the new tab opens the database at a higher version, every old tab with an open connection gets a versionchange event, and the upgrade is blocked until they close. The old tab's handler should call db.close(), then show the reload banner.

Forgetting this handler is a classic bug: the new tab hangs on a spinner because a forgotten background tab holds the old connection. The indexeddb lesson covers version upgrades in detail.

Sequence after a deploy between an old tab on build b-911 and protocol 3, IndexedDB, and a new tab on build b-912 and protocol 4. The new tab posts app hello with build b-912 and protocol 4; the old tab does not parse it but learns a newer build runs. The new tab opens the database at version 5. IndexedDB sends versionchange to the old tab, which calls db.close. Then the upgrade runs for the new tab. The old tab shows a new version banner with a Reload button, and the new tab writes both items and lines for one release.Old tabIndexedDBNew tabhello: b-912, v4open, version 5versionchangedb.close()upgrade runsbanner: new versionReload when safewrites items and linesfor one release
The old tab treats a message it cannot read as a signal, not an error. Closing the database on versionchange is what lets the new tab upgrade instead of hanging on a blocked open.

Check your understanding · Exercise 7

A tab on protocol 3 receives a message with v: 4. What should it do? Select all that apply.

Architecture, data model and testing

Every tab runs the same code. What differs is the role: one tab has the leader duties switched on, the others have them off. Keeping one bundle and one code path matters: any tab can become leader at any moment, so the leader code must already be loaded and tested in every tab.

PieceResponsibilityWhy the boundary is here
UI componentsRender from stores; start mutations; show the draft and new-version bannersThey never touch the channel or locks directly, so sync can change without touching screens.
Query cache (TanStack Query or similar)Server data for this tab, with a version per keyOne place to set data from a remote message or mark it stale.
Auth storeEpoch, user id, access token in memoryAuth has the strictest rules, so it gets its own small store.
Settings storeTheme, language, density, with a versionApplied before first paint from localStorage (see the dark mode theming guide).
Tab busTyped publish and subscribe over BroadcastChannel, envelope, validationThe only code that knows the transport; a SharedWorker could replace it later.
PersistencelocalStorage: auth:epoch, settings, app:build. IndexedDB: cached data and the version tableThe truth that survives sleep, crash and new tabs.
Leader serviceWeb Locks election, heartbeat, steal; runs the socket client, poller, early token refresh, outbox and queue runnersAll shared duties start and stop together with one AbortSignal.
Coordinatorsauth:refresh single flight; edit:draft:id locksShort locks for one action, separate from the long leader lock.
Lifecycle watcherListens to visibilitychange, pageshow, resume, online, freeze; calls catchUp()One entry point for every way a tab wakes up.
API clientAdds the token; on 401 calls getFreshToken; returns versions from responsesSingle place that turns server replies into versioned cache writes.
Architecture of multi-tab sync. At the top, the server with its API and WebSocket. Below it, two tabs with the same parts. Tab A, the leader, has UI and stores, the leader service with the socket and timers, a catch-up module and the tab bus. The server's socket connects only to tab A's leader service. Tab B, a follower, has the same parts, with its leader service waiting in the lock queue, and it sends HTTP writes to the server directly. At the bottom, the same-origin browser layer shared by every tab: Web Locks, BroadcastChannel, localStorage and IndexedDB. Both tab buses talk through BroadcastChannel, and tab B's catch-up reads IndexedDB.ServerAPI + WebSocketTab A (leader)UI + storesReact, cacheLeadersocket, timersCatch-upTab busTab B (follower)Leaderwaits in queueUI + storesReact, cacheTab busCatch-up1 socketHTTP writesShared, same originWeb LocksBroadcastChannellocalStorageIndexedDB
Every tab loads the same code; only the leader has its duties switched on. Tabs never call each other directly: they meet in the shared layer at the bottom, where the channel carries news and storage keeps the truth.

How data moves

There are two directions. Reads from the server's push: the server sends an event on the one socket, which lives in the leader tab. The leader writes the data and version to IndexedDB, applies it to its own query cache, and publishes cache/update. Each follower runs applyRemote, and React re-renders only the components that read that key.

Writes by the user: the tab where the user clicked sends the HTTP request itself, gets the new version back, applies it locally, persists it, and publishes. The leader is not involved in writes, so a suspended leader can never block a click.

Data flow. The server sends one event, cart version 19, over the socket to the leader tab. The leader persists the data and version to IndexedDB and publishes a cache update on the BroadcastChannel. The follower tab receives the message, runs applyRemote, and calls setQueryData, so the components that read the cart re-render. Writes go the other way: the follower tab sends its own HTTP write to the server, which replies with the new version. Hidden tabs only mark data stale.ServerAPI + WebSocketLeader tabsocket clientChannelFollower tabapplyRemoteIndexedDBdata + versionComponentsre-render by keyevent, v19publishmessagepersistsetQueryDataHTTP writehidden tabs:mark stale only
Pushes enter through one tab and fan out over the channel; writes leave from the tab where the user clicked, so a suspended leader never blocks a click. Every arrow carries a version, which is what lets applyRemote ignore anything older.

Think first

Suppose that in a year every browser you support has SharedWorker. What in this architecture would you change, and what would stay?

Data model: what lives where

Each piece of state lives in exactly one primary place. Messages only announce changes to it. The client-storage lesson compares the storage options in general; the table below is the choice for this app.

DataLives inSizeSynced by
Auth epoch and statelocalStorage key auth:epochUnder 100 bytesstorage event and auth/* messages
Access tokenMemory only, per tabAbout 1 KBauth/token message; never on disk
Refresh tokenHttpOnly cookieNot visible to scriptThe browser, for all tabs
SettingslocalStorage key settingsUnder 1 KBsettings/changed with version
Server dataQuery cache in memory, snapshot in IndexedDBKB to a few MBcache/* messages with server versions
Version tableIndexedDB store versionsOne row per keyRead by catchUp()
Draft textIndexedDB store drafts, autosaved every 2 sUp to a few hundred KBNot synced; guarded by edit:draft:id lock
Route, scroll, selectionURL and component stateSmallNever
Leader and rolesThe Web Lock app:leaderNothing storedThe browser
ts
type AuthRecord = {
  schema: 1;
  epoch: number;          // bumped on every sign-in and sign-out
  state: "signed-in" | "signed-out";
  userId: string | null;
};

type SettingsRecord = {
  schema: 2;
  version: number;        // highest seen + 1 on each write
  writer: string;         // tab id, breaks ties
  theme: "light" | "dark" | "system";
  locale: string;
  density: "comfortable" | "compact";
};

type VersionRow = { key: string; version: number; savedAt: number }; // IndexedDB "versions"

type DraftRow = { draftId: string; text: string; baseVersion: number; savedAt: number }; // IndexedDB "drafts"
The persisted shapes. Every record carries a schema version so a newer build can migrate it.

The schema field is for the deploy problem from the protocol chapter. A newer build that finds schema: 1 in localStorage migrates the record and writes it back with the new number; an older build that finds a schema it does not know leaves the record alone and uses defaults. Neither ever throws on a record another build wrote.

Check your understanding · Exercise 8

Which placements would you push back on in a code review? Select all that apply.

Testing many tabs

Multi-tab bugs do not show up in unit tests of one tab; they need a real browser with several pages sharing one profile. Playwright gives exactly that: pages created from the same browser context share cookies, localStorage, IndexedDB, BroadcastChannel and Web Locks, like tabs in one window. Pages from two different contexts behave like two separate profiles, which is how you test that sync does not leak between users. The general approach to end-to-end tests is in the e2e-tests lesson.

ts
test("logout everywhere and leader hand-over", async ({ context }) => {
  const [a, b, c] = await Promise.all([context.newPage(), context.newPage(), context.newPage()]);
  for (const p of [a, b, c]) await p.goto("/inbox");

  // Exactly one tab leads.
  const roles = await Promise.all([a, b, c].map((p) => p.evaluate(() => window.__sync.role)));
  expect(roles.filter((r) => r === "leader")).toHaveLength(1);

  // Close the leader without warning; another tab takes over.
  const leader = [a, b, c][roles.indexOf("leader")];
  await leader.close({ runBeforeUnload: false });
  const rest = [a, b, c].filter((p) => p !== leader);
  await expect.poll(() => Promise.all(rest.map((p) => p.evaluate(() => window.__sync.role))))
    .toContain("leader");

  // Logout in one remaining tab signs out the other.
  await rest[0].getByRole("button", { name: "Sign out" }).click();
  await expect(rest[1]).toHaveURL(/\/signin/);
});
Logout in one page reaches the others, and exactly one socket survives a leader crash.
  • Frozen or crashed leader. In Chromium, a CDP session can freeze a page: const cdp = await context.newCDPSession(page); await cdp.send("Page.setWebLifecycleState", { state: "frozen" }). Assert that a visible follower steals after 15 s (use page.clock to skip the wait), then set the state back to active and assert the old leader closed its socket. cdp.send("Page.crash") simulates a renderer crash for the hand-over test.
  • bfcache. Navigate away and back with page.goBack(), then assert catchUp() ran (expose a counter in test builds).
  • Old and new builds. A contract test feeds the current parser messages recorded from the previous release and asserts it never throws. Run it in CI on every change to sync-protocol.ts.
  • Duplicate refresh. Expire the token in three pages at once, then assert that the mock server saw exactly one POST /auth/refresh.

Expose role, epoch and versions on a debug object only in test and development builds, and add a small "sync" panel to the in-app debug menu showing the tab id, role, last heartbeat and protocol. It pays off the first time someone reports "two of my tabs disagree".

Performance, security and privacy

Avoiding broadcast storms

A broadcast storm is when sync traffic grows faster than the changes behind it. Picture a bulk action that marks 500 emails read. If each change publishes one cache/update and each of 6 tabs answers by invalidating and refetching the inbox, that is 500 messages, 2,500 handler runs, and up to 2,500 GET requests in a second. Worse, if a receiver re-publishes what it applied, two tabs can echo a message back and forth forever.

  • Never re-publish what you received. Only the tab that caused a change (or the leader, for socket events) publishes it. The isNewer check also stops echoes, since an echo is never newer.
  • Batch per frame. The bus collects messages for one requestAnimationFrame (about 16 ms; in a hidden tab, where frames do not run, until the next microtask) and sends one cache/invalidate with all keys.
  • Send diffs or keys, not whole lists. "Messages 1 to 500 are read, version 9,120" is a few hundred bytes; the full inbox is hundreds of KB to clone into every tab.
  • Debounce settings. A slider for font size publishes once the user stops moving it for 200 ms, not on every input event.

Watch localStorage in particular. Every setItem is a synchronous write the main thread waits for, and every write also wakes every other tab with a storage event. Keep it to the three tiny keys in the data model, written only on real changes; a cache write on every socket event belongs in IndexedDB, which is asynchronous.

Measure in the Performance panel: one sync message should cost well under 1 ms to receive, validate and apply; anything that shows up as a long task (over 50 ms) and hurts Interaction to Next Paint is a bug.

Security and privacy

BroadcastChannel, localStorage, Web Locks and IndexedDB are all scoped to the origin (and, in modern browsers, partitioned further when the page is embedded as a third party). That is a feature: stay inside it.

  • Never use window.postMessage to sync with other windows or iframes of another origin, and never send tokens to "*". If an embedded widget from another origin needs to know the user signed out, send it a plain signed-out event with a checked target origin, never a token.
  • Treat every message as input. Validate its shape before applying it; a bug or an injected script on the same origin can post anything on the channel.
  • Logout wipes, it does not hide. Every tab clears its query cache, closes and deletes the user's IndexedDB databases, and calls caches.delete for any user data the service worker cached. The server can also send Clear-Site-Data: "cache" on the logout response. Avoid its "storage" directive here: it also erases auth:epoch, the record that tells a sleeping tab it was signed out.
  • Shared devices. Offer a "public computer" sign-in that keeps nothing in IndexedDB at all, and shorten the idle timeout. An idle timeout must count activity across tabs: a tab the user is typing in should keep the session alive for the others, so tabs publish a throttled activity ping (once a minute at most) and the timer runs in the leader.

Check your understanding · Exercise 9

A bulk action changes 500 items while 6 tabs are open. Which designs keep it cheap? Select all that apply.

Running it in the interview

This question rewards structure. Candidates who start with "I'd use BroadcastChannel" spend 40 minutes on one API. Candidates who start with "what must sync, and how strongly" lead the interview. A 45-minute plan:

MinutesWhat to doWhat to say out loud
0 to 5Ask the scope questions: token storage, socket, editing in two tabs, browsers, deploy rate"Tabs share disk but not memory. Every bug here comes from that gap."
5 to 12Draw the five-class table: auth, settings, server data, drafts, UI state"Logout must be instant and certain. Theme can be eventual. Route must never sync."
12 to 20Pick the transport; draw tabs, channel and storage"Messages are hints, storage is truth. A sleeping tab catches up from storage."
20 to 28Leader election with Web Locks; the hand-over table"The browser releases the lock on close or crash. A heartbeat covers a leader that is suspended."
28 to 36Auth: epoch for logout, single-flight refresh"Rotating refresh tokens punish two tabs refreshing at once, so refresh runs under a lock with a re-check."
36 to 42Versions, deploys, storms"Old and new builds talk to each other, so the protocol is versioned and receivers are tolerant."
42 to 45Testing and metrics"Sockets per user and refreshes per session tell us in production if the election broke."

Draw the architecture early, with three tabs side by side, the channel under them, storage under that, and the server on the right connected to only one tab. That one picture answers half the follow-ups before they are asked.

Follow-ups to expect

  • "Why not a SharedWorker?" It removes the election, and support is now broad, but Samsung Internet lacks it and Chrome for Android only added it in 2026. You still need a fallback, and it still does not help a tab that slept. Present it as an upgrade behind the tab bus.
  • "What if two tabs edit the same ticket?" One editor at a time with an edit:ticket:id lock and a banner; the save carries the base version and the server returns 409 on a stale one. Merge only if the product truly needs it, and then it is a CRDT problem.
  • "The leader tab is on a slow phone in the background. Is that a problem?" Only if it stops running. The heartbeat lets a visible tab steal, and the old leader's rejected lock promise shuts its socket.
  • "How do you sync across devices?" You do not, here: that goes through the server. The same versions make the two paths agree, because a tab applies whatever is newer whether it came from another tab or from the server.
  • "What breaks first at scale?" Message volume in large tabs, not the number of tabs: batch, send keys and versions, and keep hidden tabs quiet.

Check your understanding · Exercise 10

Which openings show the strongest signal in the first five minutes? Select all that apply.

Summary

  • Tabs share disk, cookies and locks, but not memory. Decide what crosses that gap, per kind of state: auth immediately, settings eventually, server data by version, drafts never silently, UI state never.
  • Messages are hints, storage is truth. BroadcastChannel carries small notices; localStorage (tiny keys) and IndexedDB (data and versions) hold the state a waking tab reads in catchUp().
  • Elect one leader with a Web Lock for the socket, polling, timers and background sync. The browser releases the lock on close or crash; a heartbeat lets a visible tab steal from a suspended leader.
  • Logout bumps an auth epoch on disk and broadcasts it; every tab wipes memory and caches and leaves for sign-in, even if the server call fails.
  • Refresh tokens under an auth:refresh lock, so tabs never send the same rotating refresh token at once, with a re-check inside so the server sees one refresh per expiry.
  • Every value carries a version, so applying a message is idempotent and order-free. Server data uses the server's version.
  • Version the protocol, read tolerantly, expand then contract, and let old tabs ask to reload. Close IndexedDB on versionchange.
  • Test with several pages in one Playwright context, freeze and crash the leader, and watch sockets per user and refreshes per session in production.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 3
Mid-level 0 of 4

References

Concepts behind it

Try next