
How to Synchronize UI State Without LocalStorage Polling
The BroadcastChannel API offers a native, event-driven way to keep disparate browser contexts in sync without the performance overhead of storage event hacks.
I once spent three hours debugging a "Zombie Cart" issue where a user had four tabs open, deleted an item in one, and then watched in confusion as it resurrected itself in another tab five seconds later. I was using localStorage events to sync state, but the lag and the weird edge cases made the whole UI feel like it was haunted.
If you’ve ever tried to keep multiple browser tabs in sync, you’ve likely reached for window.addEventListener('storage', ...). It works, but it feels like a hack. You’re essentially using the browser’s long-term disk storage as a makeshift message bus. It’s slow, it’s string-based, and it’s overkill for "Hey, the user clicked a button."
The Better Way: BroadcastChannel
The BroadcastChannel API is the native browser tool we actually deserve. It allows scripts from the same origin to send messages to each other across different windows, tabs, or iframes. It’s event-driven, handles objects natively (no more JSON.parse everywhere), and is significantly more performant than polling or storage events.
Here is the "Hello World" of syncing a simple message:
// Tab A: Create the channel
const bc = new BroadcastChannel('auth_status');
// Send a message
bc.postMessage({ status: 'logged_out', user: 'brian' });
// Tab B: Listen for the message
const bc = new BroadcastChannel('auth_status');
bc.onmessage = (event) => {
console.log(event.data); // { status: 'logged_out', user: 'brian' }
};Why this beats the 'Storage' event
When you use localStorage, you’re writing to the disk. That’s a heavy operation just to tell another tab to refresh a list. Plus, the storage event only fires if the value *actually changes*. If you try to send the same message twice, the listener won't fire.
BroadcastChannel doesn't care about the state; it cares about the *event*. It’s a literal pipe between contexts.
A Real-World Example: The Global Theme Switcher
Let’s say you have a dark mode toggle. You want the user to click it in the settings tab and see every other open tab flip to dark mode instantly.
const themeChannel = new BroadcastChannel('theme_sync');
function setTheme(theme) {
document.body.className = theme;
// Tell everyone else
themeChannel.postMessage(theme);
}
// Listen for updates from other tabs
themeChannel.onmessage = (event) => {
document.body.className = event.data;
// Update your toggle UI here too
const toggle = document.querySelector('#theme-toggle');
if (toggle) toggle.checked = event.data === 'dark';
};
// Cleanup when the component/page unmounts
// This is important! Don't leave channels hanging.
window.addEventListener('unload', () => {
themeChannel.close();
});Handling Complex State (The Redux/Zustand pattern)
If you're using a state management library, you can hook this directly into your store. Instead of syncing the whole state object (which is expensive), just broadcast the *actions*.
Imagine a simple state sync for a notification bell:
const msgChannel = new BroadcastChannel('app_state_sync');
const state = {
notifications: []
};
function dispatch(action, isInternal = true) {
switch(action.type) {
case 'NEW_NOTIFICATION':
state.notifications.push(action.payload);
renderUI();
break;
case 'CLEAR_ALL':
state.notifications = [];
renderUI();
break;
}
// If this action originated in this tab, tell the others
if (isInternal) {
msgChannel.postMessage(action);
}
}
// Listen for actions from other tabs
msgChannel.onmessage = (event) => {
// Pass false to 'isInternal' to prevent an infinite loop!
dispatch(event.data, false);
};The "Gotchas" you need to know
As much as I love this API, it isn't magic.
1. Same-Origin Only: You can't broadcast from mysite.com to blog.mysite.com if they are on different subdomains (depending on your CORS setup/origin policy). They must share the exact same origin.
2. No "Buffering": If a tab is closed or the script isn't running when the message is sent, it misses the message. There is no history. If you need a "new" tab to know what happened ten minutes ago, you still need localStorage or a database to persist that state.
3. Serialization: You can send objects, arrays, strings, and Blobs, but you can’t send functions, DOM elements, or anything with a circular reference. The browser uses the structured clone algorithm, so keep your payloads data-heavy and logic-light.
4. Memory Leaks: Always call channel.close() when you’re done. If you're using React, do this in the useEffect cleanup return.
When should you actually use it?
If you are building a dashboard where data needs to be fresh across windows, or a checkout flow that shouldn't let a user pay twice, BroadcastChannel is your best friend. It’s cleaner, it avoids the "disk-writing" overhead of storage hacks, and it makes your app feel significantly more responsive.
Just remember: BroadcastChannel is for communication, while localStorage is for persistence. Use the right tool, and your users (and your future self) will thank you.

