cf.completefrontend
My progress
FUNDAMENTALS · MODULE 10

Syncing state across tabs

Every 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.

Why tabs drift apart

Picture a project management app. A user keeps three tabs open all day: the board in tab A, a task they are editing in tab B, and their inbox in tab C. This is normal. Many people keep the same app open in several tabs, and some keep it open for days.

In tab A they switch the theme to dark, and tabs B and C stay light. In tab B they rename a task, and tab A still shows the old name on the board.

In tab C they click Sign out. Tabs A and B still show private data, and the next click there fails with a 401 error, the HTTP status for "you are not signed in".

Nothing here is a bug in any single tab. Each tab did exactly what it was told. The problem is that nobody told the other tabs. This lesson is about how to tell them, and about the harder questions that come next: which tab owns the shared WebSocket, which tab refreshes the token, and what happens when two tabs edit the same thing.

Each tab has its own memory

A browser tab runs your JavaScript in its own heap, the area of memory where your objects live. Your Redux store, your Zustand store, your React context, and your TanStack Query cache are all plain objects in that heap. Open the same URL in a second tab and the browser loads the app again from scratch: new heap, new store, new cache, new WebSocket.

Chrome usually puts tabs of the same site in separate operating system processes, but even when two tabs share a process they never share a JavaScript heap. There is no way for tab B to read a variable in tab A. The only things the tabs share are the things the browser stores per origin, the scheme, host, and port of the page (for example https://app.example.com).

Three tabs of a project app, each with its own memory, above one shared storage area. Tab A switched to the dark theme but still shows task Ship v1 and Hi, Asha. Tab B renamed the task to Ship v2 but still shows the light theme and Hi, Asha. Tab C signed out but still shows the light theme and Ship v1. Shared storage holds localStorage, IndexedDB, and cookies.tab A: boardtheme: darktask: Ship v1Hi, Ashaown heaptab B: tasktheme: lighttask: Ship v2Hi, Ashaown heaptab C: inboxtheme: lighttask: Ship v1signed outown heapshared storagelocalStorage, IndexedDB, cookies
Each tab made one change and is out of date on the other two. They share the storage underneath, but nothing in their memory heard about the other tabs' changes.

What the tabs do share

Tabs of the same origin share storage, not memory. Writing to storage is how state survives a reload, but on its own it does not keep open tabs in step: a tab that already read a value at startup keeps its old copy in memory until something tells it to read again.

Storage is shared per origin; memory and connections are per tab.
ThingShared by same-origin tabs?Do other tabs hear about changes?
JavaScript variables, stores, React stateNo, one copy per tabNo
In-memory query cache (TanStack Query, SWR, Apollo)No, one copy per tabNo
CookiesYesOnly through the Cookie Store API change event, which is not in every browser
localStorageYesYes, through the storage event
sessionStorageNo, one copy per tabNo
IndexedDBYesNo built-in event; you must send a message yourself
HTTP cache and Cache StorageYesNo
WebSocket and EventSource connectionsNo, one per tab that opens oneNo

What drift costs you

Drift is not only a cosmetic problem. It shows up in four ways, and each one is a common interview follow-up.

  • Stale UI. One tab shows data another tab already changed. The user acts on old data, for example moving a task that was already deleted.
  • Security leaks. After sign-out in one tab, other tabs keep private data on screen and in memory. On a shared computer the next person can read it.
  • Wasted connections. Ten tabs each open their own WebSocket. The server holds ten connections for one person, and the user receives every notification ten times.
  • Races on shared resources. Two tabs notice an expired access token at the same moment and both use the same refresh token. If the server rotates refresh tokens, the second request fails and signs the user out.

Check your understanding · Exercise 1

Two tabs of https://app.example.com are open. Which of these does tab B see right away, without any sync code, after tab A changes it? Select all that apply.

The mental model: tell, share, or own

Think of your open tabs as flatmates. They share a fridge (storage), but each has their own head (memory). Nobody knows you finished the milk unless you say so. There are three ways to run a flat like that, and they map one to one onto the browser tools.

  1. Tell. Each flatmate keeps their own view, and whoever changes something posts a note in the group chat. In the browser: every tab keeps its own store and sends small messages with the storage event or BroadcastChannel.
  2. Share. There is one notebook on the kitchen table that everyone reads and writes. In the browser: one copy of the state lives in a SharedWorker, and tabs ask it for data instead of keeping their own.
  3. Own. One flatmate is in charge of the shopping for everyone. When they move out, someone else takes the job. In the browser: the tabs elect a leader tab with the Web Locks API, and only the leader does the shared work, such as holding the WebSocket or refreshing the token.

Real apps mix all three. A chat app might tell other tabs about a theme change, own the WebSocket in a leader tab, and share nothing. A heavy email client might share a whole mail cache in a SharedWorker. Before reaching for an API, decide which of the three jobs you need for each piece of state.

A layered stack inside one origin. Top layer: tabs A, B, and C, each with its own memory. Tell layer: the storage event, which carries strings and persists, and BroadcastChannel, which carries objects in memory. Own and share layer: a leader tab chosen with Web Locks, and a SharedWorker holding one copy reached through ports. Bottom layer: shared storage with localStorage, IndexedDB, and cookies.tabstellown, sharestoragetab Aown memorytab Bown memorytab Cown memorystorage eventstrings, persistsBroadcastChannelobjects, in memoryleader tabchosen with Web LocksSharedWorkerone copy, via portsshared storagelocalStorage, IndexedDB, cookies
Tabs never share memory, so every sync design picks from the middle layers: tell the other tabs, let one owner do the work, or keep one shared copy. Storage at the bottom is shared but silent, apart from the storage event.

What to put in a message

Once tabs can talk, you must decide what they say. There are three kinds of message, and picking the wrong one is a common source of bugs.

Send events for actions, values for small client state, and invalidations for server data.
KindExampleReceiver doesGood for
Event: what happened{ type: "auth.logout" }Runs its own logic for that eventLogout, login, "user switched account"
State: the new value{ type: "theme", value: "dark" }Copies the value into its storeSmall values: theme, language, cart count
Invalidation: what is stale{ type: "invalidate", key: ["tasks", 42] }Marks its cached copy stale and refetches if shownServer data: tasks, messages, profiles

Invalidation is the safest default for server data. The server stays the single source of truth, and each tab refetches only what it shows. Sending whole objects across tabs is faster, but now two copies of the truth can disagree if a message is lost or arrives late. The cache invalidation lesson covers the same trade-off between servers and browsers; here it repeats between tabs.

A tiny protocol

Treat the channel between tabs like a small network API. Give every message a type, a version of the protocol, and the id of the tab that sent it. Tabs of different app versions can be open at once after a deploy, so an old tab must ignore types it does not know rather than crash on them.

ts
type TabMessage =
  | { v: 1; from: string; type: "auth.logout" }
  | { v: 1; from: string; type: "auth.login"; userId: string }
  | { v: 1; from: string; type: "auth.token"; expiresAt: number }
  | { v: 1; from: string; type: "settings.theme"; value: "light" | "dark" }
  | { v: 1; from: string; type: "query.invalidate"; key: readonly unknown[] };

export const tabId = crypto.randomUUID();

export function isTabMessage(data: unknown): data is TabMessage {
  return typeof data === "object" && data !== null && (data as { v?: unknown }).v === 1;
}
A typed union of messages. The from id lets a tab ignore its own echoes and helps when debugging.

Check your understanding · Exercise 2

Tab A renames task 42. Tabs B and C both show a list that includes task 42. Which messages are reasonable choices for tab A to post? Select all that apply.

The storage event: the oldest signal

The storage event has worked in every browser for over a decade. When one tab changes localStorage, the browser fires a storage event in every other same-origin window: other tabs, and same-origin iframes. The tab that made the change does not get the event. That is usually what you want: it already knows.

Step by step

Say the user picks dark mode in tab A. Here is what happens, in order.

  1. Tab A applies the theme to its own page, then calls localStorage.setItem("theme", "dark"). The old value was "light".
  2. The browser writes the new value to the origin's storage. The write is synchronous: tab A's script waits until it is done.
  3. The browser queues a storage event for every other window of the same origin: tabs B and C here, and any same-origin iframes inside them.
  4. Tabs B and C run their listener a moment later, when their event loop is free. The event carries key: "theme", oldValue: "light", newValue: "dark", the url of the page that wrote it, and storageArea.
  5. Each listener applies the theme. Nothing is written back, so nothing loops.
A sequence diagram with tab A, localStorage, tab B, and tab C. Tab A applies the dark theme itself, then calls setItem theme dark. The browser sends a storage event to tab B and another to tab C, each carrying key theme, oldValue light, and newValue dark. Tab A gets no event.tab AlocalStoragetab Btab CsetItem(theme)storage eventstorage eventno event in A:it already knowskey: themeoldValue: light, newValue: dark
The writer gets no event: it already applied the change. Every other same-origin tab gets one storage event with the key and both values, as strings.
ts
export function setTheme(theme: "light" | "dark") {
  applyTheme(theme); // this tab
  localStorage.setItem("theme", theme); // persist, and signal other tabs
}

window.addEventListener("storage", (event) => {
  if (event.storageArea !== localStorage) return;
  if (event.key === null) return applyTheme("light"); // localStorage.clear()
  if (event.key === "theme" && event.newValue) {
    applyTheme(event.newValue === "dark" ? "dark" : "light"); // never trust the raw string
  }
});
The writer applies the change itself. Other tabs apply it from the event. key is null when another tab called clear().

The rules that trip people up

  • Strings only. localStorage stores strings, so objects must go through JSON.stringify and JSON.parse. A Date comes back as a string and a Map comes back as {}. Wrap JSON.parse in a try because another version of your app may have written a different shape.
  • No change, no event. Writing the same value again does not fire an event. If you use a key as a one-off signal ("log out now"), write something unique each time, such as a timestamp, or the second logout will be silent.
  • Synchronous and small. Every read and write blocks the main thread, and each origin gets about 5 MB. Fine for a theme or a flag. Bad for a 2 MB cache that you rewrite on every change.
  • sessionStorage does not help. It is a separate store per tab, so other tabs never see its changes.
  • Persistence is a side effect. The value stays on disk after every tab closes. That is good for a theme, but a signal key like logout-at stays behind too, so clean it up or ignore old values.

Because of these limits, treat the storage event as the right tool for small values that should also persist, such as theme, language, or a "last signed-out at" timestamp. For anything else there is a purpose-built message bus, covered next. The storage event is still worth knowing as a fallback for very old browsers and embedded webviews.

Check your understanding · Exercise 3

Tab A runs localStorage.setItem("logout", "1") every time the user signs out. Which statements are true? Select all that apply.

BroadcastChannel: a message bus for your tabs

BroadcastChannel is a named channel that every same-origin tab, iframe, and worker can join. Post a message on it and every other member with the same channel name receives it. Nothing is written to disk. It has been Baseline since March 2022, when Safari 15.4 shipped it, which means it works in every current major browser.

ts
const channel = new BroadcastChannel("app");

// Omit from each member of the union, so every message type keeps its own fields
type WithoutMeta<T> = T extends unknown ? Omit<T, "v" | "from"> : never;

export function post(message: WithoutMeta<TabMessage>) {
  channel.postMessage({ ...message, v: 1, from: tabId });
}

channel.addEventListener("message", (event) => {
  if (!isTabMessage(event.data)) return; // unknown version or shape
  const message = event.data;
  if (message.type === "auth.logout") signOutLocally();
  if (message.type === "settings.theme") applyTheme(message.value);
  if (message.type === "query.invalidate") queryClient.invalidateQueries({ queryKey: message.key });
});
One channel instance for the whole tab, carrying the typed messages from the last chapter. Call channel.close() only when a component that owns a channel unmounts for good.

Messages are copied with structured clone

The browser copies each message with the structured clone algorithm, the same deep copy that postMessage and structuredClone() use. That is the big upgrade over the storage event: you can send objects, arrays, Date, Map, Set, ArrayBuffer, Blob, and File without turning them into strings.

Some things cannot be cloned. Functions, DOM nodes, and objects that hold them throw a DataCloneError on the sending side. Class instances arrive as plain objects, so methods and instanceof checks are lost. Send plain data and rebuild classes on the receiving side if you need them.

The rules of the channel

  • The sender's own object does not hear itself. Other BroadcastChannel objects with the same name do, even in the same tab. So two modules in one tab that each open new BroadcastChannel("app") will hear each other. Use one shared instance per tab, or filter on from.
  • Same origin, same storage partition. Tabs of https://app.example.com cannot talk to https://admin.example.com. A third-party iframe is also partitioned by the top-level site, so the same widget embedded on two different sites gets two separate channels.
  • Only running pages hear it. There is no history and no queue on disk. A closed tab, or a page sitting in the back/forward cache, misses the message entirely. A tab the browser has frozen in the background runs it only when it wakes, if the browser does not discard the tab first.
  • No reply built in. It is fire and forget. If you need an answer, such as "what is the current user?", send a request with an id and have the other tabs answer with the same id.
  • Order per sender, not global. In practice, messages from one tab arrive in the order it posted them, because each post queues a task in every receiver and tasks run in order. Nothing orders messages from two different tabs: they can arrive in different orders at different receivers, so do not use the channel to decide who wins.
A matrix of four mechanisms against five questions. Carries objects: storage event no, BroadcastChannel, SharedWorker, and leader tab yes. Persists to disk: only the storage event. One shared owner: SharedWorker and leader tab yes, the other two no. Every browser: yes for all except SharedWorker, which is not in all browsers. No handover gap: not applicable to the storage event and BroadcastChannel, yes for SharedWorker, and a short gap for the leader tab.storageeventBroadcastChannelSharedWorkerleadertabcarries objectsnoyesyesyespersists to diskyesnononoone shared ownernonoyesyesevery browseryesyesnot allyesno handover gapn/an/ayesshort
The storage event and BroadcastChannel only carry messages. SharedWorker and a leader tab also give you one owner per browser, which is what a shared socket or a single token refresh needs.

Decision Which tool should carry messages between tabs?

storage event

Write a key to localStorage

Other tabs listen for storage

Pros

  • Works everywhere, including old webviews
  • The value also persists across restarts

Cons

  • Strings only, about 5 MB, synchronous
  • Same value twice fires nothing
  • Leaves keys behind on disk

BroadcastChannel

Every tab opens new BroadcastChannel("app")

Post typed messages; others receive them

Pros

  • Structured clone: real objects, files, dates
  • Nothing on disk, no main-thread storage cost
  • Works in workers too

Cons

  • No history: closed or cached tabs miss messages
  • No replies or ordering across senders

Relay through a worker

Each tab connects to a SharedWorker (or messages the service worker)

The worker forwards to every connected tab

Pros

  • One place that can also hold state and connections
  • Can answer requests and keep history

Cons

  • More code and a separate script to debug
  • Service workers can be stopped at any time

What we pick: Use BroadcastChannel as the default bus. Use the storage event when the value should persist anyway, or as a fallback. Move to a worker relay only when you also want one shared owner of state or connections, which the next chapters cover.

Check your understanding · Exercise 4

A tab posts channel.postMessage({ type: "draft.saved", savedAt: new Date(), onDone: () => toast("Saved") }). What happens? Select all that apply.

SharedWorker: one copy for all tabs

Messages keep tabs informed, but each tab still holds its own copy of everything. A SharedWorker changes that. It is a background script that all same-origin tabs share: the first tab that creates it starts it, later tabs connect to the same running instance, and the browser stops it when the last connected tab closes.

That makes it a natural home for things you want once per browser rather than once per tab: one WebSocket, one in-memory cache, one queue of pending writes, one timer that refreshes the token. Tabs become thin views that ask the worker for data and render it.

Before and after. On the left, three tabs each open their own socket to the server: 3 sockets and 3 copies of every event. On the right, the same three tabs connect through ports to one SharedWorker, which holds the only socket to the server: 1 socket, and each event arrives once and is forwarded to every port.a socket per tabone SharedWorkerservertab 1tab 2tab 33 sockets, 3 copiesserverSharedWorker1 sockettab 1tab 2tab 31 socket, ports
Same tabs, same server. With a SharedWorker the browser holds one connection and one copy of the data, and the tabs become views connected through ports.

How it looks in code

Each tab creates the worker with the same URL and talks to it through a port, a two-way message pipe that also uses structured clone. Inside the worker, a connect event fires once per tab with that tab's port.

ts
// hub.worker.ts: runs once per browser
const ports = new Set<MessagePort>();
let socket: WebSocket | null = null;

function ensureSocket() {
  if (socket) return;
  socket = new WebSocket("wss://chat.example.com/live");
  socket.onmessage = (event) => {
    for (const port of ports) port.postMessage({ type: "server", data: event.data });
  };
  socket.onclose = () => { socket = null; setTimeout(ensureSocket, 2000); };
}

self.addEventListener("connect", (event) => {
  const port = (event as MessageEvent).ports[0];
  ports.add(port);
  port.onmessage = (msg) => {
    if (msg.data.type === "send") socket?.send(JSON.stringify(msg.data.payload));
    if (msg.data.type === "bye") ports.delete(port);
  };
  ensureSocket();
});

// In each tab:
// const hub = new SharedWorker("/hub.worker.js", { name: "hub", type: "module" });
// hub.port.onmessage = (e) => render(e.data);
One socket, many ports. Each server message is received once and forwarded to every connected tab.

The catches

  • Browser support. Desktop Chrome, Edge, and Firefox have had it for years, and Safari brought it back in version 16 (2022). Chrome on Android only supports shared workers from version 148 (2026), so many Android users still need a fallback, usually the leader tab from the next chapter.
  • Knowing when a tab is gone. A port gives no reliable signal when its tab closes or crashes, so the set of ports slowly fills with dead ones. A bye message on pagehide covers normal closes. For crashes, each tab can hold a Web Lock named after its id, and the worker requests the same lock: it is granted only when that tab is gone.
  • No DOM and no localStorage. The worker has fetch, WebSocket, IndexedDB, timers, and Web Locks, but it cannot touch the page. All rendering stays in the tabs.
  • Deploys. The worker keeps running the old code until every tab closes. A tab with the new bundle can connect to an old worker, so version your messages and let the worker tell a newer tab to reload.
  • Debugging. It runs in its own context with its own console. In Chrome you find it under chrome://inspect/#workers.

Why not the service worker?

Your app may already have a service worker, and it can message every tab with clients.matchAll() and client.postMessage(). It is a fine relay for short jobs: "a push notification arrived, tell all tabs". It is a poor owner for long-lived things. The browser stops an idle service worker after about 30 seconds in Chrome, and you cannot keep it alive on purpose, so a WebSocket held there drops at random.

Check your understanding · Exercise 5

You move your chat socket into a SharedWorker. Which statements are true? Select all that apply.

Leader election with Web Locks

When a SharedWorker is not available, or you want to keep everything in normal tab code, let the tabs pick one of themselves to do the shared work. That tab is the leader; the others are followers. The leader holds the WebSocket, runs the polling loop, or refreshes the token, and posts what it learns on a BroadcastChannel. When the leader tab closes, another tab must take over, quickly and without two tabs ever thinking they lead.

Electing a leader by hand is surprisingly hard. Older libraries wrote a heartbeat timestamp to localStorage every second and treated a stale timestamp as a dead leader. That breaks when background timers are throttled, and two tabs can both decide the leader is dead and both take over. The Web Locks API solves this in the browser itself.

How Web Locks work

navigator.locks.request(name, callback) asks for a named lock that is shared by every tab and worker of the same origin. The browser grants it to one requester at a time and queues the rest. The holder keeps the lock until the promise returned by its callback settles.

If the holding tab closes, navigates away, or crashes, the browser releases the lock for it. Web Locks has been Baseline since March 2022, the same Safari 15.4 release as BroadcastChannel.

ts
const channel = new BroadcastChannel("chat");
let isLeader = false;

navigator.locks.request("chat-leader", async () => {
  isLeader = true; // only one tab at a time gets here
  const socket = new WebSocket("wss://chat.example.com/live");
  socket.onmessage = (event) => {
    const message = JSON.parse(event.data);
    showMessage(message); // this tab
    channel.postMessage({ type: "server", message }); // every other tab
  };
  channel.onmessage = (event) => {
    if (event.data.type === "send") socket.send(JSON.stringify(event.data.payload));
  };
  await new Promise(() => {}); // never settle: lead until this tab goes away
});

channel.addEventListener("message", (event) => {
  if (!isLeader && event.data.type === "server") showMessage(event.data.message);
});
Every tab runs this. One gets the lock and opens the socket; the rest wait in the queue and listen on the channel. Followers send outgoing messages to the leader as send.
A state machine for one tab in leader election. A new tab calls request and becomes a follower waiting in the queue. When the lock is granted it becomes the leader, which holds the socket. If another tab steals the lock, the leader is stolen from, steps down by closing its socket, and requests again as a follower. If the leader tab closes, the lock is released and the next tab in the queue leads.new tabcalls request()followerwaits in queueleaderholds the socketstepping downclose socket, timerslock releasednext tab leadsrequest()grantedstolenre-requesttab closes
The browser moves the lock; your code must move the work. The step-down state is the one people forget: after a steal, the old leader learns only from a rejected request promise.

The options that matter

The navigator.locks.request options. navigator.locks.query() shows who holds and who waits, which helps when debugging.
OptionWhat it doesUse it forMistake to avoid
(none)Wait in the queue until the lock is freeClassic leader election: the next tab takes over automaticallyForgetting that the callback must stay pending, or the lock is released at once
ifAvailable: trueDo not wait: the callback gets null if someone else holds it"Refresh the token only if nobody else is doing it right now"Treating null as an error instead of "another tab is on it"
steal: trueTake the lock now; the old holder loses itMove leadership to the tab the user is looking atNot stopping the old leader: it keeps its socket unless it reacts
signalAn AbortSignal that cancels a waiting requestStop waiting when a component unmounts or after a timeoutAborting after the lock was granted does nothing
mode: "shared"Many holders at once, but none while an exclusive holder has itReaders and writers of the same IndexedDB dataUsing it for leaders: everyone becomes a leader

Stealing leadership safely

Why steal at all? Browsers throttle hidden tabs: Chrome can run timers in a long-hidden tab only once a minute, and may freeze it completely. A leader in a hidden, frozen tab is a leader that does nothing. Some apps therefore let the visible tab steal leadership on visibilitychange.

When a tab steals, the old holder's lock is released at once and the promise returned by its request call rejects with an AbortError. That rejection is the only notice the old leader gets. It must catch it, close its socket, and go back to being a follower. If it ignores it, you have split brain: two tabs both acting as leader, two sockets, and every message shown twice.

Check your understanding · Exercise 6

Three tabs request "chat-leader" in order A, B, C. A holds it with a never-settling promise. What happens next? Select all that apply.

One WebSocket per browser

The most common reason to elect a leader is a real-time connection. The WebSockets lesson showed that each open socket costs the server memory and a file descriptor, and costs the device battery for keep-alive pings. Multiply that by tabs and the numbers grow fast.

Take a team chat app with 200,000 people online at peak. If the average user keeps 3 tabs open, one socket per tab means 600,000 connections. One socket per browser means 200,000: a third of the servers, and a third of the connection storm after a deploy, when every socket reconnects at once. The user also gets each notification once instead of three times.

Decision Who should own the real-time connection?

Every tab

Each tab opens its own socket on load

Pros

  • Simplest code; no coordination
  • Tabs are fully independent, so one broken tab cannot hurt another

Cons

  • N tabs means N sockets and N copies of every event
  • Duplicate sounds and desktop notifications

Leader tab

Tabs elect a leader with Web Locks

The leader owns the socket and relays over BroadcastChannel

Followers send outgoing messages to the leader

Pros

  • One socket in every browser that has Web Locks
  • Plain tab code, easy to debug in DevTools

Cons

  • A short gap when leadership moves
  • A hidden, throttled leader can lag
  • More moving parts than one socket per tab

SharedWorker

A shared worker owns the socket

Tabs connect through ports

Pros

  • Not tied to any tab, so closing tabs causes no handover
  • Not throttled like a hidden tab

Cons

  • Older Chrome on Android lacks it, so you still need a fallback
  • Separate script, separate debugging, stale code after deploys

What we pick: For an app that people keep open in several tabs (chat, email, dashboards), use a SharedWorker where it exists and a leader tab elsewhere, behind one small RealtimeClient interface so the rest of the app does not care which one runs. For a site where multiple tabs are rare, one socket per tab is fine and much simpler.

The handover gap

When the leader tab closes, its socket closes with it. The next tab gets the lock almost at once, but it still has to open a new socket: a TCP connection, a TLS handshake, and the WebSocket upgrade, each needing a network round trip. That usually takes from tens to a few hundred milliseconds, and longer on a slow mobile network. Any event the server sent in that gap is lost unless you plan for it.

  • Resume from a cursor. The leader broadcasts the id of each event it relays. Every follower remembers the last id. The new leader connects with ?after=evt_9041 and the server replays what was missed.
  • Or refetch. If the server cannot replay, the new leader broadcasts query.invalidate for the live views, and every tab refetches once.
  • Do not double-show. Keep a small set of recent event ids per tab and drop any event already seen. Replays and duplicate relays then do no harm.

Lab: three tabs, one browser

The lab below simulates three tabs of a chat app. Pick how the tabs coordinate and who owns the socket, then close tabs, hide the leader, sign out, and let two tabs refresh the token at the same moment. Watch the socket count, which tabs still show private data, and which races appear.

Sync
Owner
Three tabs. 1 sockets open, 0 token refresh calls, 0 sounds played, 0 tabs still showing private data after logout. Tab 1: signed in, visible, leader, 0 events. Tab 2: signed in, visible, follower, 0 events. Tab 3: signed in, visible, follower, 0 events.serversockets: 1refresh calls: 0sounds: 0exposed tabs: 0tab 1signed inevents: 0leadervisibletab 2signed inevents: 0followervisibletab 3signed inevents: 0followervisibleBroadcastChannel
Lines from the server are open sockets. Try closing the leader, hiding it, signing out from one tab, and letting the token expire, under each setting.
Tab 1
Tab 2
Tab 3

All three tabs called navigator.locks.request. Tab 1 got the lock first, so it leads and holds the socket; tabs 2 and 3 wait in the queue.

Think first

In the lab, choose a leader tab, close the leader, then hide the new leader and sign out from the last tab. What do you see, and why?

Check your understanding · Exercise 7

You use a leader tab for the chat socket. Which extra pieces do you need for a correct design? Select all that apply.

Logout, token refresh, and server caches

Three jobs come up in almost every app that people open in several tabs: signing out everywhere, refreshing the auth token once, and keeping server data in step. Each one combines the tools from the earlier chapters.

Logout in every tab

When the user clicks Sign out in tab A, two things must happen. The server must end the session, which is what actually protects the data. And every tab must drop the private data it holds in memory and on screen, because a closed session does not erase a rendered inbox.

A sequence diagram with tab A, the server, tab B, and tab C. Tab A sends DELETE /api/session and the server answers 204 and clears the cookie. Tab A posts auth.logout on the BroadcastChannel; tab B clears its caches and reloads to the sign-in page. Tab C was in the back/forward cache and missed the broadcast. When the user returns to it, it requests the inbox, gets 401, and signs out locally.tab Aservertab Btab CDELETE session204, cookie clearedauth.logoutclear caches,reload to sign-intab C was cached:missed itGET /inbox401: sign out
The server call protects the data, the broadcast clears running tabs at once, and the 401 or the pageshow and visibility checks catch a tab that was cached or frozen.
ts
export async function signOut() {
  await fetch("/api/session", { method: "DELETE", credentials: "include" });
  post({ type: "auth.logout" }); // BroadcastChannel to the other tabs
  localStorage.setItem("auth:logout-at", String(Date.now())); // fallback signal
  signOutLocally();
}

export function signOutLocally() {
  realtime.close(); // leader or worker connection
  queryClient.clear(); // all cached server data
  useStore.setState(initialState); // client stores
  window.location.replace("/sign-in"); // a full load drops anything left in memory
}

// Safety nets in every tab
api.onUnauthorized(signOutLocally); // any 401
document.addEventListener("visibilitychange", () => {
  if (document.visibilityState === "visible") checkSessionOrSignOut();
});
window.addEventListener("pageshow", (event) => {
  if (event.persisted) checkSessionOrSignOut(); // restored from back/forward cache
});
Server first, then the broadcast, then local cleanup. The safety nets catch tabs that were frozen or cached during the broadcast.

Sign-in needs the same care in reverse. If a tab sees auth.login with a different user id than the one it shows, it must not merge data from two accounts. Reload it, or clear and refetch everything. The auth tokens lesson covers the server side of logout: revoking the refresh token and clearing cookies.

Refreshing the token once

With short-lived access tokens, every tab finds out at roughly the same moment that its token expired. If each tab calls /auth/refresh with the same refresh token, and the server rotates refresh tokens (issues a new one and invalidates the old one on each use), the second call looks like a stolen token being reused. Many servers then revoke the whole session and the user is signed out for no reason.

The fix is a lock around the refresh and a check inside it. The first tab to get the lock refreshes and shares the result; tabs that were waiting find a fresh token when their turn comes and skip the call. The auth tokens lesson adds the same guard inside one tab, so eight 401 responses in a tab also start only one refresh.

ts
async function getFreshToken(): Promise<string> {
  return navigator.locks.request("auth-refresh", async () => {
    const current = await tokenStore.read(); // shared: IndexedDB or the leader's memory
    if (current && current.expiresAt - Date.now() > 30_000) return current.token; // someone else refreshed
    const next = await refreshWithServer(); // only one tab gets here per expiry
    await tokenStore.write(next);
    post({ type: "auth.token", expiresAt: next.expiresAt });
    return next.token;
  });
}
Lock, re-check, then refresh. The re-check inside the lock is what stops the second tab from refreshing again.
Two timelines from 0 to 300 milliseconds. Without a lock, tab A refreshes with refresh token rt1 from 0 to 120 ms, and tab B also refreshes with rt1 from 10 to 130 ms; the server sees rt1 reused and signs the user out. With a Web Lock, tab A takes the lock and refreshes from 0 to 120 ms, saving the new token. Tab B waits for the lock from 10 to 120 ms, then re-checks, finds a fresh token, and skips its refresh.without a locktab Arefresh rt1tab Brefresh rt1reuse: signed outwith a Web Locktab Alock + refreshnew token savedtab Bwaits for lockskipre-check: fresh0 ms100 ms200 ms300 ms
Same two tabs, same moment. The lock turns two refresh calls into one, and the re-check inside the lock is what lets tab B skip its call.

Keeping server-state caches in step

Each tab has its own TanStack Query, SWR, or Apollo cache. Out of the box, they catch up on their own when the window regains focus: TanStack Query refetches stale queries on focus by default. That covers the common case where the user switches tabs and looks. It does not cover two tabs side by side on a big screen.

  • Broadcast invalidations after mutations. After a successful mutation, post query.invalidate with the affected keys. Other tabs call invalidateQueries, which refetches only what is on screen. This is the pattern from the cache invalidation lesson, applied between tabs.
  • Broadcast the new data for tiny hot values. An unread count or cart badge can travel as data with setQueryData, saving a request in every tab.
  • A ready-made plugin. TanStack Query offers broadcastQueryClient in the @tanstack/query-broadcast-client-experimental package, which mirrors cache updates to other tabs over a broadcast channel. As the name says, it is experimental; check its current status before relying on it.
  • A shared cache. Heavy clients, such as an email app with thousands of cached messages, keep the cache in a SharedWorker or in IndexedDB, and tabs read from there. More work, but data is fetched and stored once.

Check your understanding · Exercise 8

Three tabs hit an expired access token within 20 ms of each other. The server rotates refresh tokens and treats reuse as theft. Which designs make exactly one refresh call for this expiry and never sign the user out? Select all that apply.

Conflicts and failure modes

Once tabs share work, new bugs appear that a single tab never had. Most come from one fact: tabs run at different speeds, and some are not running at all.

Two tabs editing the same draft

The user opens the same task in two tabs, edits the description in one, forgets, and edits it in the other. Both tabs autosave the draft to the same IndexedDB key every few seconds. Whichever saves last wins, and the other tab's work disappears without a word. This is last write wins, and it is the default whenever you do nothing.

Strategy

Two tabs, one draftStep 1 of 4: Both tabs open

Two tabs, A and B, edit the same draft, which is saved in IndexedDB below them. Each step shows what each tab displays and what the store holds.tab AFix loginbase v3tab BFix loginbase v3draft:42v3: Fix login
Tabs A and B both load draft 42 at version 3: "Fix login".

Both tabs open: Tabs A and B both load draft 42 at version 3: "Fix login".

Decision How should the app handle the same draft open in two tabs?

Last write wins

Each tab saves whenever it likes

Pros

  • No code
  • Fine when a draft is short or rarely open twice

Cons

  • Silent data loss
  • Users blame the product, not themselves

Detect and ask

Save a version with each draft

Broadcast draft.saved with the version

A tab that sees a newer version than its base shows a banner before its next save

Pros

  • No silent loss
  • Small amount of code

Cons

  • The user must choose: reload theirs or keep mine
  • Still one winner

One editor at a time

The editing tab holds a Web Lock named draft:42

Other tabs get null with ifAvailable and open read-only

A "Use here" button takes over with steal: true

Pros

  • Conflicts cannot happen
  • Clear for the user

Cons

  • Feels strict
  • The stolen tab must save and step down cleanly

Merge

Model the draft as operations or a CRDT

Tabs exchange operations over the channel

Pros

  • Both edits survive
  • Same code serves real-time collaboration later

Cons

  • Much more complex
  • Overkill unless you already merge for collaboration

What we pick: For forms and drafts, detect and ask is the usual choice. Use one editor at a time when a conflict would be costly, like a payment form or a long document without merging. Reach for merging only if the product already does real-time collaboration; the conflict resolution lesson goes deeper.

WhatsApp Web is a familiar example of one owner at a time. At the time of writing, opening it in a second tab shows a message that WhatsApp is open in another window, with a "Use here" button that moves it to this tab. The other tab then shows the same message. WhatsApp has not published how it builds this, but it behaves like leader election with stealing, shown to the user.

What breaks in production

Most fixes are cheap once you know the failure exists.
FailureWhat the user seesFix
Frozen or cached tab misses or delays a broadcastPrivate data still on screen after logout; old pricesRe-check on visibilitychange and pageshow; refetch stale queries on focus
Hidden leader is throttledMessages arrive late in every tabLet the visible tab steal leadership, or use a SharedWorker
Old leader ignores being stolen fromTwo sockets, every message twiceStep down when the request promise rejects; dedupe by event id
Echo loopCPU at 100%, the UI flickersNever re-broadcast received messages; ignore your own from
Two app versions after a deployA tab crashes on an unknown messageVersion the protocol; ignore unknown types; ask old tabs to reload
Notification shown per tabThree sounds for one messageOnly the leader, or only the focused tab, notifies
Account switched in another tabData from two users mixed on one pageOn auth.login with a new user id, reload

A few cases are not failures at all, but surprise people. A private window has its own storage, so it never syncs with normal tabs. Different browser profiles and different browsers never sync either. Tabs on app.example.com and www.example.com are different origins and cannot use any of these APIs together: only the server can bring those in step.

Check your understanding · Exercise 9

A user reports: "After I signed out, my laptop still showed my balance in another tab the next morning." Which fixes address the cause? Select all that apply.

Using it in a system design interview

Cross-tab sync rarely is the whole question, but it is a strong follow-up in any design where users keep the app open: chat, email, a trading dashboard, a project board, a document editor. Interviewers like it because it tests whether you think about the real browser and not an idealized single page.

When to bring it up

  • You draw a WebSocket or SSE connection. Say: "Users keep this open in several tabs, so I would keep one connection per browser with a leader tab or a SharedWorker."
  • You discuss auth. Mention logout in every tab and refreshing the token once under a Web Lock.
  • You discuss a client cache. Mention that each tab has its own cache, and that you broadcast invalidations after mutations.
  • You discuss drafts or offline edits. Mention what happens when the same draft is open twice.
A decision flowchart. First question: must the value persist? If yes, use the storage event with the value in localStorage. If no, next question: do you need one owner, such as a socket or a token refresh? If no, use BroadcastChannel for events and invalidations. If yes, next question: is SharedWorker in every browser you support? If yes, use a SharedWorker with one socket and one cache. If no, use a leader tab chosen with Web Locks and a BroadcastChannel.must it persist?storage eventsmall, persisted valuesneed one owner?BroadcastChannelevents, invalidationsSharedWorker in all?SharedWorkerone socket, one cacheleader tabWeb Locks + channelyesnonoyesyesno
Most apps end up with two answers at once: BroadcastChannel for messages, and a leader tab or SharedWorker for the few shared resources.

What to draw

Draw the browser as a box with two or three tab boxes inside it, and the server outside. Inside the browser, draw the BroadcastChannel as a bar the tabs connect to. Mark one tab as leader with a single line to the server. That one picture answers "how many connections?", "how do tabs learn about changes?", and "what happens when this tab closes?" in a few seconds.

How deep to go by level

LevelWhat a strong answer includes
JuniorKnows tabs do not share memory; names the storage event and BroadcastChannel; handles logout in every tab
MidPicks events versus invalidations; avoids echo loops; one socket per browser with a leader; refreshes the token once
SeniorWeb Locks with steal and step-down; handover gap with resume cursors; frozen and cached tabs; version skew after deploys; SharedWorker with a fallback and its support limits
StaffConnects it to server cost (connections per user), to security (logout is a server action), and to the product (one editor at a time versus merge)

Think first

You are designing a web email client. Users keep it open in 4 tabs on average. Sketch your cross-tab plan in four sentences.

Summary

  • Every tab has its own memory: stores, query caches, and sockets exist once per tab. Tabs of the same origin share only storage, cookies, and the HTTP cache.
  • Decide per piece of state whether other tabs must know. Sync auth, settings, and server data; leave drafts in progress, scroll positions, and open menus per tab.
  • Use BroadcastChannel as the default bus with small, typed, versioned messages; send events for actions, values for tiny state, and invalidations for server data. Never re-broadcast what you receive.
  • The storage event is the old, universal fallback: strings only, synchronous, no event when the value does not change.
  • Keep one owner per browser for shared resources such as the WebSocket and the token refresh: a SharedWorker where available, otherwise a leader tab chosen with Web Locks. Plan the handover gap and the step-down after a steal.
  • Logout is a server action first and a broadcast second. Cached tabs miss broadcasts and frozen tabs handle them late, so re-check the session on visibilitychange, pageshow, and 401.
  • Two tabs editing the same thing is last write wins unless you add versions, a per-draft lock, or merging.

In the interview

Questions interviewers ask about this. Answer each out loud first, then compare.

Question 1

A user has your chat app open in 6 tabs. How do you avoid 6 WebSocket connections?

Question 2

How do you log the user out of every open tab, reliably?

Question 3

What is the difference between the storage event and BroadcastChannel, and when would you use each?

Question 4

Two tabs refresh the access token at the same time and the user gets signed out. Why, and how do you fix it?

Question 5

How does leader election with Web Locks handle a hidden, throttled leader?

Practice it

These design problems lean on syncing state across tabs.

References

Read next