Your Tabs Can Talk to Each Other: BroadcastChannel
A user files a bug: "I logged out, but another tab still showed me logged in, and I placed an order as... myself?" Multi-tab inconsistency is one of those problems everyone has and few explicitly handle - partly because people don't know the fix is three lines.
const channel = new BroadcastChannel('app-state');
// Tab that logs out:
channel.postMessage({ type: 'logout' });
// Every OTHER tab on the same origin:
channel.onmessage = (e) => {
if (e.data.type === 'logout') location.href = '/login';
};
Same-origin tabs, windows, iframes and even workers subscribed to the same channel name receive the message. Payloads go through structured clone, so objects, Dates and Maps survive - same algorithm as structuredClone. The sender never receives its own message, which conveniently prevents echo loops.
What I actually sync with it
// Theme toggle - all tabs flip together
channel.postMessage({ type: 'theme', value: 'dark' });
// Cart updates - badge count stays right everywhere
channel.postMessage({ type: 'cart', count: cart.length });
// "This record is being edited in another tab" soft locks
channel.postMessage({ type: 'editing', recordId: 42 });
That last one is underrated for admin panels: a heartbeat message every few seconds while a form is open, and other tabs show "also open elsewhere" instead of letting two copies of the same record fight.
The legacy route: the storage event
Before BroadcastChannel, cross-tab messaging was a localStorage hack that still works and still matters for old Safari:
// Sender
localStorage.setItem('broadcast', JSON.stringify({ type: 'logout', at: Date.now() }));
// Receivers - fires in every tab EXCEPT the one that wrote
addEventListener('storage', (e) => {
if (e.key === 'broadcast') handle(JSON.parse(e.newValue));
});
Two quirks define it: the event only fires in other tabs (a feature, same as BroadcastChannel), and it only fires when the value actually changes - hence the Date.now() salt so repeated identical messages still deliver. It's clunkier (strings only, pollutes storage), but it reaches back a decade.
Notes from production
- Messages are fire-and-forget to currently open listeners - a tab that opens later hears nothing. Pair the channel with persisted state (localStorage/cookie) for late joiners: read state on load, listen for changes after.
- Close channels you create in components (channel.close()) or the listener keeps the context alive.
- Support: everywhere since Safari finally shipped it in 15.4 (2022). For anything older, the storage event is the graceful path.
For logout specifically, do both: broadcast for instant UI response, and validate the session server-side on the next request anyway - client-side sync is UX, not security.
Full-stack web developer sharing practical tutorials and building tools that ship.
Got something on your mind?
My inbox is open - no forms disappearing into the void here.
- Just say hello Found a tutorial useful? Spotted a mistake? Tell me.
- Hire me for a project Have something custom in mind? Let's talk scope and timelines.
- Product support Bought something here? I'll help you get it running.
I usually reply within 1-2 business days.