Broadcast Channels
Overview
Open your app in two browser tabs and change something in one of them. Does the other tab notice? Usually not, and that is a real product problem. A user logs out in one tab while the other still shows their account. They add an item to the cart in one tab and the other shows the old cart. They switch to dark mode here, and the tab next to it stays bright.
The BroadcastChannel API is the simplest way to fix this. It lets different tabs, windows, iframes, and workers of the same site talk to each other directly in the browser, with no server in the middle. One tab posts a message on a named channel, and every other context listening on that same channel receives it.
// Every tab opens the same named channelconst channel = new BroadcastChannel('auth');// One tab broadcasts when the user logs outchannel.postMessage({ type: 'logout' });// Every other tab reactschannel.addEventListener('message', (event) => { if (event.data.type === 'logout') { redirectToLogin(); }});That is the whole idea: a named channel that any same-origin context can join, send on, and listen to.
The API
The whole API is small. You create a channel by name, send messages, listen for them, and close it when you are done.
Creating a channel
const channel = new BroadcastChannel('auth');That is the entire setup. Any other code on the same origin that also runs new BroadcastChannel('auth') is now on the same channel. There is no registration, no handshake, and no server. When you post a message, the browser finds every other channel with the same name and delivers it for you, so you never have to track which tabs are open.
"Same origin" means the exact combination of scheme, host, and port. https://example.com and http://example.com are different origins, and so are localhost:3000 and localhost:3001. Code on one origin cannot see another origin's channels. That is a security boundary, not a setting you can turn off.
Sending a message
channel.postMessage({ type: 'login', user: { id: 7, name: 'Ada Lovelace' },});You can send almost any value, not just strings. The browser copies it with the structured clone algorithm, which handles objects, arrays, Map, Set, Date, ArrayBuffer, Blob, and nested combinations of those. What it cannot copy is anything tied to running code or the page: functions, DOM nodes, and class instances with methods. If your message contains one of those, postMessage throws a DataCloneError immediately.
One behavior surprises people: a channel never receives its own messages. If tab A posts on the auth channel, tabs B and C hear it, but tab A does not hear itself. You are broadcasting to everyone else, not to yourself.
Receiving messages
channel.addEventListener('message', (event) => { console.log(event.data); // whatever was passed to postMessage});The handler receives a MessageEvent, and event.data holds the cloned message. If you only need one handler, you can assign channel.onmessage instead.
Handling errors
channel.addEventListener('messageerror', (event) => { console.error('Could not read message', event);});The messageerror event fires when the browser receives a message but cannot deserialize it. On a single origin running similar code this almost never happens, but it can come up if a worker on the other side sends data the receiver cannot reconstruct.
Closing the channel
channel.close();Once closed, a channel stops sending and receiving, and it cannot be reopened. If you need it again, create a new instance. In React, the natural place to do this is the useEffect cleanup function:
useEffect(() => { const channel = new BroadcastChannel('auth'); channel.addEventListener('message', (event) => { if (event.data.type === 'logout') redirectToLogin(); }); return () => channel.close();}, []);Do not skip the cleanup. If you open a channel on every mount and never close it, each unmount leaves an open channel and its listener behind. Those stale listeners keep firing on every message, which leaks memory and causes bugs, like a handler trying to update state on a component that no longer exists.
Notice there is no removeEventListener here, and you do not need one. close() tears down the whole channel, listeners included. You would only reach for removeEventListener if you wanted to detach a single handler while keeping the channel open for others, which is uncommon. For the usual "set it up on mount, tear it down on unmount" case, close() is all you need.
What BroadcastChannel is not
It is easy to expect more from BroadcastChannel than it offers. Three things it is not:
- It is not a store. There is no current value to read. You only receive messages posted after you start listening. Open a fresh tab and the login broadcast from five seconds ago is already gone. If you need the current state on load, you have to get it another way (see below).
- It is not cross-origin. Unlike
window.postMessage, which can target a window on another origin, BroadcastChannel is locked to your own origin. You cannot use it to talk betweenapp.example.comandapi.example.com. - It does not guarantee global ordering. Messages from one sender reach one receiver in order, but if two tabs post at the same moment, other tabs may see them in different orders. For auth, theme, and notification syncing this does not matter. If you need strict ordering, put a sequence number or timestamp in the message.
Getting the current state on load
Because a channel has no memory, a tab that opens later misses everything posted before it started listening. There are two common ways to close that gap.
The first is a request and response handshake. A new tab asks for the current state, and any existing tab answers.
const channel = new BroadcastChannel('auth');// A new tab asks for the current statechannel.postMessage({ type: 'request-state' });// Existing tabs answerchannel.addEventListener('message', (event) => { if (event.data.type === 'request-state') { channel.postMessage({ type: 'state', value: currentAuth, }); }});The second, and often simpler, is to pair the channel with localStorage. Write the current state to localStorage on every change, read it once on load for the initial value, and use the channel only for live updates.
// Read the initial value synchronously on loadconst stored = localStorage.getItem('auth');if (stored) setAuth(JSON.parse(stored));// Then listen for live changesconst channel = new BroadcastChannel('auth');channel.addEventListener('message', (event) => { if (event.data.type === 'auth') setAuth(event.data.value);});This gives you a synchronous initial read and live cross-tab updates at the same time.
Common use cases
BroadcastChannel is for lightweight, fire-and-forget coordination across tabs, iframes, and workers of the same site. The patterns that come up most often:
Cross-tab session sync
The most common one. A user logs in on one tab, and every other tab should reflect it right away. Same for logout. Without this, each tab is an island that only learns about a session change on the next page load or server poll.
const session = new BroadcastChannel('session');// on loginsession.postMessage({ type: 'login', user: currentUser });// on logoutsession.postMessage({ type: 'logout' });This also solves the nasty "logged out in one tab, still clicking around in another" case. A logout broadcast can redirect every tab to the login page and clear local caches before any stale request goes out.
Theme and preferences
Toggle dark mode in one tab and the rest should follow. Same for language, font size, or reduced motion.
const prefs = new BroadcastChannel('preferences');prefs.postMessage({ type: 'theme', value: 'dark' });You would usually pair this with localStorage so the choice survives a reload, and use the channel only to tell already-open tabs. localStorage has its own cross-tab storage event, but that only fires on localStorage writes, while BroadcastChannel is a general message bus.
Cache invalidation
When one tab fetches fresh data and writes it to IndexedDB or a local cache, the other tabs are now stale. A message tells them to refetch or reread.
const cache = new BroadcastChannel('cache');cache.postMessage({ type: 'invalidate', keys: ['profile', 'notifications'],});Talking to a Service Worker
A Service Worker can open a BroadcastChannel too, so it can tell every tab about a push notification, a finished background sync, or an offline-to-online switch, without managing a list of clients by hand.
// in the service workerconst updates = new BroadcastChannel('sw-updates');self.addEventListener('push', (event) => { updates.postMessage({ type: 'push', data: event.data.json(), });});// in the pageconst updates = new BroadcastChannel('sw-updates');updates.addEventListener('message', (event) => { if (event.data.type === 'push') showNotification(event.data.data);});Same-user collaboration across tabs
For live cursors or shared selections across tabs the same user has open on one document, BroadcastChannel propagates changes locally before any server round-trip. The server side (WebSocket or SSE) handles syncing between different users, while BroadcastChannel handles the same user's own tabs.
When to reach for it
Reach for BroadcastChannel when you need same-origin coordination across tabs, iframes, or workers, and you want it with zero dependencies. It is the one primitive that works across all of those at once, and it does not care whether the two sides share a build or a bundle. The contract is just a channel name and the shape of the messages.
The tradeoff is that it is asynchronous and has no memory, so plan for the initial-state gap, and do not lean on it for anything that needs strict ordering or a readable current value.
BroadcastChannel has been in every major browser for years, yet many teams still reach for a WebSocket or polling when all they need is to keep a few tabs in sync. For that job it is the boring, built-in answer that already works, and boring answers that already work are usually the right place to start.