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).
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.
| Thing | Shared by same-origin tabs? | Do other tabs hear about changes? |
|---|---|---|
| JavaScript variables, stores, React state | No, one copy per tab | No |
| In-memory query cache (TanStack Query, SWR, Apollo) | No, one copy per tab | No |
| Cookies | Yes | Only through the Cookie Store API change event, which is not in every browser |
localStorage | Yes | Yes, through the storage event |
sessionStorage | No, one copy per tab | No |
| IndexedDB | Yes | No built-in event; you must send a message yourself |
| HTTP cache and Cache Storage | Yes | No |
WebSocket and EventSource connections | No, one per tab that opens one | No |
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
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.
- 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
storageevent orBroadcastChannel. - 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. - 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.
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.
| Kind | Example | Receiver does | Good for |
|---|---|---|---|
| Event: what happened | { type: "auth.logout" } | Runs its own logic for that event | Logout, login, "user switched account" |
| State: the new value | { type: "theme", value: "dark" } | Copies the value into its store | Small values: theme, language, cart count |
| Invalidation: what is stale | { type: "invalidate", key: ["tasks", 42] } | Marks its cached copy stale and refetches if shown | Server 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.
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;
}
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.
- Tab A applies the theme to its own page, then calls
localStorage.setItem("theme", "dark"). The old value was"light". - The browser writes the new value to the origin's storage. The write is synchronous: tab A's script waits until it is done.
- The browser queues a
storageevent for every other window of the same origin: tabs B and C here, and any same-origin iframes inside them. - 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", theurlof the page that wrote it, andstorageArea. - Each listener applies the theme. Nothing is written back, so nothing loops.
storage event with the key and both values, as strings.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
}
});
key is null when another tab called clear().The rules that trip people up
- Strings only.
localStoragestores strings, so objects must go throughJSON.stringifyandJSON.parse. ADatecomes back as a string and aMapcomes back as{}. WrapJSON.parsein atrybecause 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.
sessionStoragedoes 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-atstays 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.
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 });
});
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
BroadcastChannelobjects with the same name do, even in the same tab. So two modules in one tab that each opennew BroadcastChannel("app")will hear each other. Use one shared instance per tab, or filter onfrom. - Same origin, same storage partition. Tabs of
https://app.example.comcannot talk tohttps://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.
Decision Which tool should carry messages between tabs?
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.
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.
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);
});
send.request promise.The options that matter
| Option | What it does | Use it for | Mistake to avoid |
|---|---|---|---|
| (none) | Wait in the queue until the lock is free | Classic leader election: the next tab takes over automatically | Forgetting that the callback must stay pending, or the lock is released at once |
ifAvailable: true | Do 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: true | Take the lock now; the old holder loses it | Move leadership to the tab the user is looking at | Not stopping the old leader: it keeps its socket unless it reacts |
signal | An AbortSignal that cancels a waiting request | Stop waiting when a component unmounts or after a timeout | Aborting after the lock was granted does nothing |
mode: "shared" | Many holders at once, but none while an exclusive holder has it | Readers and writers of the same IndexedDB data | Using 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?
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_9041and the server replays what was missed. - Or refetch. If the server cannot replay, the new leader broadcasts
query.invalidatefor 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.
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?
Closing the leader passes the lock to the next tab in the queue, which opens a new socket, so the count stays at 1. In a real browser it dips to 0 while the new socket connects, and the new leader resumes from the last event id so no messages are lost.
Signing out broadcasts auth.logout, but the hidden leader is frozen and has not handled it. It still holds the socket and private data, so the count stays at 1 and one tab is exposed. When you show it, its visibilitychange check finds no session, signs it out, and closes the socket. That is why every tab re-checks the session when it becomes visible.
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.
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
});
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.
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;
});
}
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.invalidatewith the affected keys. Other tabs callinvalidateQueries, 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
broadcastQueryClientin the@tanstack/query-broadcast-client-experimentalpackage, 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.
Two tabs, one draftStep 1 of 4: Both tabs open
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?
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
| Failure | What the user sees | Fix |
|---|---|---|
| Frozen or cached tab misses or delays a broadcast | Private data still on screen after logout; old prices | Re-check on visibilitychange and pageshow; refetch stale queries on focus |
| Hidden leader is throttled | Messages arrive late in every tab | Let the visible tab steal leadership, or use a SharedWorker |
| Old leader ignores being stolen from | Two sockets, every message twice | Step down when the request promise rejects; dedupe by event id |
| Echo loop | CPU at 100%, the UI flickers | Never re-broadcast received messages; ignore your own from |
| Two app versions after a deploy | A tab crashes on an unknown message | Version the protocol; ignore unknown types; ask old tabs to reload |
| Notification shown per tab | Three sounds for one message | Only the leader, or only the focused tab, notifies |
| Account switched in another tab | Data from two users mixed on one page | On 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.
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
| Level | What a strong answer includes |
|---|---|
| Junior | Knows tabs do not share memory; names the storage event and BroadcastChannel; handles logout in every tab |
| Mid | Picks events versus invalidations; avoids echo loops; one socket per browser with a leader; refreshes the token once |
| Senior | Web 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 |
| Staff | Connects 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.
One live connection per browser for new-mail events: a SharedWorker where it exists, otherwise a leader tab elected with Web Locks, relaying over BroadcastChannel with event ids so a new leader can resume.
After a mutation such as archive or mark-read, the tab broadcasts invalidations for the affected folders; other tabs refetch only what they show, and refetch on focus as a safety net.
Logout ends the session on the server first, then broadcasts auth.logout; every tab clears caches and reloads, and every tab re-checks the session on visibilitychange, pageshow, and any 401.
Token refresh runs under a Web Lock with a re-check inside, and a draft open in two tabs gets a version check and a "newer version in another tab" banner.
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
BroadcastChannelas 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
storageevent 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
SharedWorkerwhere 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, and401. - 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?
Keep one connection per browser. Every tab calls navigator.locks.request("chat-leader", ...); the tab that gets the lock opens the socket and keeps the lock by never settling its callback. It relays each incoming event on a BroadcastChannel, and followers send outgoing messages to it over the same channel.
When the leader closes or crashes, the browser releases the lock and the next tab in the queue takes over. It reconnects with the last event id so the server can replay the gap, and every tab drops duplicate event ids. Where SharedWorker is available, the worker can own the socket instead, which avoids handovers; keep the leader tab as the fallback for browsers without it.
Question 2
How do you log the user out of every open tab, reliably?
The tab that signs out calls the server to end the session and revoke the refresh token; that is the real protection. Then it posts auth.logout on a BroadcastChannel, with a localStorage key as a fallback, and clears its own caches and stores.
Every other tab clears its query cache, store, and connections, then does a full navigation to the sign-in page. Because a back/forward-cached tab misses the broadcast and a frozen one may not handle it in time, every tab also re-checks the session on visibilitychange and on pageshow with event.persisted, and treats any 401 as a sign-out.
Question 3
What is the difference between the storage event and BroadcastChannel, and when would you use each?
The storage event fires in other same-origin tabs when localStorage changes. It carries strings only, the writes are synchronous, writing the same value fires nothing, and the value stays on disk. It is good for small settings that should persist anyway, like the theme.
BroadcastChannel is a pure message bus: structured-clone messages, nothing on disk, and it works in workers. It is the default for events and invalidations. Neither has history, so tabs that are not running miss messages either way.
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?
The server rotates refresh tokens: each use returns a new one and invalidates the old. The second tab sends the already-used token, which looks like token theft, so the server revokes the session.
Wrap the refresh in a Web Lock and re-check the token inside the lock. The first tab refreshes and shares the new expiry; the others wait, see a fresh token, and skip the call. Alternatively, only the leader tab or the SharedWorker refreshes.
Question 5
How does leader election with Web Locks handle a hidden, throttled leader?
A hidden tab can have its timers slowed to once a minute or be frozen, so it makes a poor leader. The visible tab can request the lock with steal: true on visibilitychange. The old leader's request promise then rejects with an AbortError, and it must close its socket and timers and become a follower.
If it does not step down, you get two leaders. Deduplicating events by id limits the damage, but a clean step-down path is the real fix. A SharedWorker avoids the problem because it is not tied to any tab's visibility.
Practice it
These design problems lean on syncing state across tabs.
- Design a theme switcher with dark modeEasy
- Design a like button for millions of usersEasy
- Design a recently viewed products featureEasy
- Design a customer support chat widgetMedium
- Design an e-commerce cart and checkoutMedium
- Design sign up and sign inMedium
- Design permission-based UIMedium
- Design state sync across browser tabsMedium
- Design a client-side rate-limited action queueMedium
- Design a grocery delivery slot pickerMedium
- Design a chat appHard
- Design an offline sync engineHard
References
- MDN: Broadcast Channel API Same-origin messaging between tabs, iframes, and workers.
- MDN: Window storage event The event fired in other tabs when
localStoragechanges. - MDN: Web Locks API Named locks across tabs and workers, with
ifAvailable,steal, andsignal. - MDN: SharedWorker One worker shared by every same-origin tab, reached through ports.
- MDN: Structured clone algorithm What can and cannot travel in a message.
- Chrome for Developers: Page Lifecycle API Why hidden tabs are throttled and frozen, and the events to listen for.
- TanStack Query: broadcastQueryClient (experimental) A plugin that mirrors cache updates to other tabs.
Read next
- 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.
- 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.
- 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.
- 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.
- Server-state librariesTanStack Query, SWR, RTK Query, and Apollo cache server data by key, refetch stale entries in the background, and refresh them after mutations, so components stop hand-writing loading flags, races, and invalidation.
- Local vs global stateWhere each piece of state lives decides what re-renders when it changes: keep it next to its readers, lift it only as far as needed, and use context or a store with selectors only for state that many distant parts share.