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
| Question | Answer this design assumes | Why 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 API | A 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 presence | Five 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 form | We 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 mobile | Some 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 day | An old tab and a new tab will talk to each other, so messages need a version. |
| Shared or public computers? | Yes, for the banking variant | Logout 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
| Need | Target | How we will check it |
|---|---|---|
| Logout reaches other awake tabs | Under 1 s, usually under 50 ms | Playwright test with three pages |
| Shared data reaches other tabs | Under 200 ms after the change lands in the first tab | Timestamps in the message, logged in development |
| Leader hand-over after the leader tab closes | Under 1 s with Web Locks; under 10 s with the fallback | Close the leader page in a test and time the new socket |
| Sockets per user per browser | Exactly 1 while any tab is open | Server metric: connections per user and device |
| Token refresh calls per expiry | Exactly 1 | Server metric: refresh calls per session per minute |
| Main-thread cost of sync | Under 1 ms per message; no localStorage writes in hot paths | Performance panel, long tasks |
| Old and new app versions | Never crash on an unknown message | Contract 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).
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.
- 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.
- 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.
- 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.
- 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.
- 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?
Reloading throws away per-tab state the user cares about: a half-written reply, a scroll position deep in a list, an open modal. It also costs a full page load per tab, which is seconds on a slow phone.
Polling a flag has its own cost: a background tab's timers are throttled, so the reload might happen a minute late, and a frozen tab does not poll at all. Reload is still the right tool in one case: an old tab that cannot understand the new message protocol after a deploy. We will use it there, and ask the user first.
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?
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.
| Class | Examples | How fast | Who writes | On conflict |
|---|---|---|---|---|
| Auth and session | signed in or out, access token, current user id, permissions | Immediately, and must reach every tab | Only the tab that signed in, out, or refreshed | Logout always wins |
| Settings | theme, language, density, notification switches | Eventually, within a second | Any tab | Highest version wins |
| Server data cache | cart, unread counts, ticket fields, message list | Within 200 ms for visible tabs; on next focus for hidden ones | The server; tabs send changes there | Server version wins |
| Drafts and forms | an email draft, a ticket being edited, a settings form | Never merged silently | One tab at a time | Detect and warn the user |
| UI state | route, scroll, open panels, selection, search box text | Never synced | Each tab for itself | Not 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.
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?
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.
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.
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(); });
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?
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.
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
});
}
Every way a leader can go away
| What happens to the leader tab | Lock | What the design does |
|---|---|---|
| The user closes it, or it navigates to another site | Released at once | The next tab in the queue is granted the lock, opens the socket, and catches up from storage. |
| The renderer crashes | Released when the browser cleans up the dead page | Same as a close. |
| Chrome freezes it in the background | Still held | Chrome'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 browser | Held, but nothing runs | All tabs are suspended together. On resume, the leader's socket reconnects and every tab catches up from storage. |
| Chrome discards it to save memory | Released, the page is gone | Same 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 page | Same 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.
Tab A got the lock app:leader first. B and C wait in the queue.
Think first
Should the leader move to whichever tab the user is looking at?
Usually no. A WebSocket keeps working in a hidden tab, and moving the leader means closing one socket and opening another, which costs a reconnect and a catch-up request every time the user switches tabs.
There is one good reason to prefer a visible leader: mobile browsers suspend hidden tabs aggressively. A pragmatic rule is to stay put, but let a visible tab steal when the heartbeat stops, which is exactly the rule above.
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.
- Tab A bumps an auth epoch: a counter in
localStorage(auth:epoch= 8, withstate: "signed-out"). Writing it also fires astorageevent in every other tab: a second, free delivery path for the one message that must not be lost. - Tab A posts
{ type: "auth/signed-out", epoch: 8 }on the channel. - 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, andlocation.replace("/signin")so Back does not show the inbox. - Tab A calls
POST /auth/logoutto 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.
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?
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.
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;
});
}
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.
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?
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.
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);
}
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:
- Read
auth:epoch. If it moved, sign out or reload, and stop here. - Read the settings record from
localStorage(one key, under 1 KB). Apply it if its version is higher. - Read the version table from IndexedDB (key to version). Mark every cached query with a lower version as stale; the visible ones refetch.
- 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".
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.
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 };
| Message | Sent by | Also written to storage? | Receivers do |
|---|---|---|---|
auth/signed-out | Any tab | Yes: auth:epoch in localStorage | Sign out locally, wipe caches, go to sign-in |
auth/token | The tab that refreshed | No: tokens never go to disk | Replace the in-memory access token |
settings/changed | Any tab | Yes: settings in localStorage | Apply if the version is higher |
cache/update and cache/invalidate | The writing tab, or the leader relaying a socket event | Yes: data and version in IndexedDB | Set or mark stale in the query cache |
leader/ping and leader/heartbeat | A visible follower every 5 s; the leader answers | No | The follower resets its 15 s steal timer |
leader/request | A follower | No | Only the leader acts: sends on the socket and replies |
app/hello | Each tab on start | Yes: app:build if newer | Compare 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:
- 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.
- Same major or nothing: a message with a different
vis not parsed at all. It is only used as a signal: some tab runs another protocol. - Expand, then contract: for one release, new code writes both
itemsandlinesand reads either. The release after that stops writingitems. Most deploys then need no protocol bump. - Old tabs ask to reload: a tab that sees a higher protocol or a newer
app:buildshows 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.
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.
| Piece | Responsibility | Why the boundary is here |
|---|---|---|
| UI components | Render from stores; start mutations; show the draft and new-version banners | They 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 key | One place to set data from a remote message or mark it stale. |
| Auth store | Epoch, user id, access token in memory | Auth has the strictest rules, so it gets its own small store. |
| Settings store | Theme, language, density, with a version | Applied before first paint from localStorage (see the dark mode theming guide). |
| Tab bus | Typed publish and subscribe over BroadcastChannel, envelope, validation | The only code that knows the transport; a SharedWorker could replace it later. |
| Persistence | localStorage: auth:epoch, settings, app:build. IndexedDB: cached data and the version table | The truth that survives sleep, crash and new tabs. |
| Leader service | Web Locks election, heartbeat, steal; runs the socket client, poller, early token refresh, outbox and queue runners | All shared duties start and stop together with one AbortSignal. |
| Coordinators | auth:refresh single flight; edit:draft:id locks | Short locks for one action, separate from the long leader lock. |
| Lifecycle watcher | Listens to visibilitychange, pageshow, resume, online, freeze; calls catchUp() | One entry point for every way a tab wakes up. |
| API client | Adds the token; on 401 calls getFreshToken; returns versions from responses | Single place that turns server replies into versioned cache writes. |
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.
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?
The leader service could move into a SharedWorker: one socket, no election, no heartbeat, no steal. The tab bus would become a MessagePort to the worker. Because the UI only talks to the bus and the stores, screens would not change.
Persistence, versions, catchUp(), the auth epoch and the draft locks all stay. A SharedWorker dies when its last tab closes and does not help a tab that was frozen or restored from the bfcache, so storage is still the truth.
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.
| Data | Lives in | Size | Synced by |
|---|---|---|---|
| Auth epoch and state | localStorage key auth:epoch | Under 100 bytes | storage event and auth/* messages |
| Access token | Memory only, per tab | About 1 KB | auth/token message; never on disk |
| Refresh token | HttpOnly cookie | Not visible to script | The browser, for all tabs |
| Settings | localStorage key settings | Under 1 KB | settings/changed with version |
| Server data | Query cache in memory, snapshot in IndexedDB | KB to a few MB | cache/* messages with server versions |
| Version table | IndexedDB store versions | One row per key | Read by catchUp() |
| Draft text | IndexedDB store drafts, autosaved every 2 s | Up to a few hundred KB | Not synced; guarded by edit:draft:id lock |
| Route, scroll, selection | URL and component state | Small | Never |
| Leader and roles | The Web Lock app:leader | Nothing stored | The browser |
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 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.
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/);
});
- 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 (usepage.clockto skip the wait), then set the state back toactiveand 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 assertcatchUp()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
isNewercheck 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 onecache/invalidatewith 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
inputevent.
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.postMessageto 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 plainsigned-outevent 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.deletefor any user data the service worker cached. The server can also sendClear-Site-Data: "cache"on the logout response. Avoid its"storage"directive here: it also erasesauth: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
activityping (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:
| Minutes | What to do | What to say out loud |
|---|---|---|
| 0 to 5 | Ask 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 12 | Draw 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 20 | Pick the transport; draw tabs, channel and storage | "Messages are hints, storage is truth. A sleeping tab catches up from storage." |
| 20 to 28 | Leader 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 36 | Auth: 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 42 | Versions, deploys, storms | "Old and new builds talk to each other, so the protocol is versioned and receivers are tolerant." |
| 42 to 45 | Testing 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:idlock and a banner; the save carries the base version and the server returns409on 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.
BroadcastChannelcarries small notices;localStorage(tiny keys) and IndexedDB (data and versions) hold the state a waking tab reads incatchUp(). - 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:refreshlock, 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.
References
- MDN: Broadcast Channel API Same-origin messaging between tabs, frames and workers.
- MDN: Web Locks API Exclusive and shared locks, ifAvailable, steal, query.
- MDN: Window storage event Fires in other documents when localStorage changes.
- MDN: SharedWorker One worker shared by many tabs, with browser support.
- Chrome for Developers: Page Lifecycle API Active, hidden, frozen and discarded states, and the freeze and resume events.
- Chrome for Developers: Freezing on Energy Saver Which background tabs Chrome freezes, and the Web Locks exception.
- web.dev: Back/forward cache pageshow, persisted, and what keeps a page out of the bfcache.
- Auth0: Refresh token rotation Rotation and automatic reuse detection that revokes the token family.
- Playwright: Pages Several pages in one browser context, as tabs sharing storage.
Concepts behind it
- Syncing state across tabsEvery open tab of your app is a separate program with its own memory, so a change in one tab is invisible to the others until you send it across with the storage event, BroadcastChannel, a SharedWorker, or a leader tab chosen with Web Locks.
- Client storage optionsThe six places a web app can keep data on the user's device (cookies, localStorage, sessionStorage, IndexedDB, the Cache API, and OPFS), how big, how fast, and how long-lived each one is, and how to pick the right one for each kind of data.
- IndexedDBThe browser's built-in transactional database: object stores of structured values, indexes for queries, versioned migrations, and room for hundreds of megabytes, which makes it the place for offline data, drafts, and large blobs.
- WebSocketsOne long-lived connection, opened by an HTTP handshake, on which browser and server can each send a message at any moment. The usual pick for chat, multiplayer, and collaboration, and the one where you own the protocol, reconnects, and scaling.
- Authentication and token storageHow a web app proves who the user is on every request after login: sessions or tokens, where the proof lives in the browser, how it is refreshed and revoked, and how OAuth with PKCE and OIDC fit in.
- Cache invalidationHow every copy of a piece of data, from the server cache to the CDN to the open tab, learns that it is out of date: by time, by event, or by a new name, in the right order and without a stale read sneaking back in.
- Conflict resolution strategiesWhat to do when two people change the same data: from last write wins per field, to version checks with
If-Match, to three-way merges and merge screens, chosen separately for each kind of data. - End-to-end testsTests that drive a real browser through your real app like a user would: what to cover, how to keep them reliable, where test data comes from, and how to run them in CI.