DEV Community

Cover image for Stop using the localStorage hack to sync browser tabs. BroadcastChannel does it natively.
Parsa Jiravand
Parsa Jiravand

Posted on • Edited on • Originally published at bestpractic.org

Stop using the localStorage hack to sync browser tabs. BroadcastChannel does it natively.

Avoids localStorage cleanup bugs

When a user logs out in one tab, the other tabs should follow. When they update their cart, every open window should reflect it. The common solution is a localStorage trick: write a sentinel value, listen for the storage event, read it, parse it, check if it's "for you," and clean it up. It works — but it's a side-channel communication pattern built on a persistence API that was never meant for messaging. The Broadcast Channel API is the direct path.

The API

// Sender (any tab, worker, or iframe on the same origin)
const channel = new BroadcastChannel('app-sync');
channel.postMessage({ type: 'LOGOUT' });

// Receiver (every other context subscribed to the same name)
const channel = new BroadcastChannel('app-sync');
channel.onmessage = (event) => {
  console.log(event.data); // { type: 'LOGOUT' }
};
Enter fullscreen mode Exit fullscreen mode

Two steps: open a channel by name, then send or listen. Any tab, worker, or iframe on the same origin that opens a channel with the same name receives every message sent on it — including messages sent after they subscribed. The sender does not receive its own messages.

Close the channel when you're done to release the listener:

channel.close();
Enter fullscreen mode Exit fullscreen mode

What the localStorage approach actually looks like

The typical cross-tab sync pattern using storage events:

// Sender
localStorage.setItem('__broadcast', JSON.stringify({ type: 'LOGOUT', t: Date.now() }));
localStorage.removeItem('__broadcast'); // clean up immediately

// Receiver
window.addEventListener('storage', (event) => {
  if (event.key !== '__broadcast') return; // filter noise
  if (!event.newValue) return;             // ignore the removeItem
  const message = JSON.parse(event.newValue);
  if (message.type === 'LOGOUT') { /* handle */ }
});
Enter fullscreen mode Exit fullscreen mode

Every part of this is load-bearing workaround: the timestamp prevents deduplication if the same value is sent twice; the removeItem triggers a second storage event that must be filtered out; JSON.stringify/JSON.parse is required because storage only holds strings. BroadcastChannel replaces the entire block with a postMessage call.

Real-world use cases

Logout across all tabs. When the user logs out, invalidate the session in every open window simultaneously:

// auth.js — runs in every tab
const syncChannel = new BroadcastChannel('auth');

export function logout() {
  clearSession();
  syncChannel.postMessage({ type: 'SESSION_ENDED' });
  redirect('/login');
}

syncChannel.onmessage = (event) => {
  if (event.data.type === 'SESSION_ENDED') {
    clearSession();
    redirect('/login');
  }
};
Enter fullscreen mode Exit fullscreen mode

Cart sync in an e-commerce app. Add to cart in one tab, see the count update in the header of every other tab:

const cartChannel = new BroadcastChannel('cart');

function addToCart(item) {
  const updated = updateLocalCart(item);
  cartChannel.postMessage({ type: 'CART_UPDATED', cart: updated });
  renderCart(updated);
}

cartChannel.onmessage = (event) => {
  if (event.data.type === 'CART_UPDATED') {
    renderCart(event.data.cart);
  }
};
Enter fullscreen mode Exit fullscreen mode

Live config refresh. When an admin changes a feature flag in a settings tab, broadcast the update so every other open tab picks it up without a page reload.

What you can send

BroadcastChannel uses the structured clone algorithm — the same one used by structuredClone() and postMessage() on workers. That means you can send:

  • Plain objects and arrays (including nested)
  • Date, Map, Set, ArrayBuffer, Blob
  • Primitive values — strings, numbers, booleans, null

You cannot send functions, DOM nodes, or anything not serializable by structured clone. If you try, the call throws a DataCloneError. For the message payloads most apps actually use — event objects with typed fields — structured clone covers everything without the JSON roundtrip.

Scope and limits

BroadcastChannel is scoped to same-origin contexts — same protocol, hostname, and port. A channel named 'app-sync' on https://example.com is completely isolated from a channel with the same name on https://staging.example.com. You cannot use it to communicate between different origins.

The channel name is your namespace. If multiple features in your app use BroadcastChannel, give each a distinct name ('auth', 'cart', 'notifications') rather than sharing a single 'app' channel and multiplexing message types through it — separate channels are cleaner and don't require filtering.

Browser support

BroadcastChannel is Baseline 2022: Chrome 54 (2016), Firefox 38 (2015), Safari 15.4 (March 2022). The API has been in Chromium and Firefox for nearly a decade; Safari joined in 2022. It's available in all currently-supported browser versions and in Web Workers and Service Workers, not just the main thread.

🎮 Try it yourself

▶️ Open the interactive playground →

Runs right in your browser — poke at it and watch the concept react live.

🧠 Test yourself

Think it clicked? Take the 9-question quiz →

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.

The takeaway

Search your codebase for storage event listeners paired with a localStorage.setItem that immediately gets removed. That pattern is cross-tab messaging through a storage side-channel — exactly what BroadcastChannel exists to replace. Swap it out: open a channel by name, call postMessage, listen with onmessage. You get structured data without serialization, no storage event noise to filter, and no cleanup sentinel to manage. The intent becomes clear in the code; the runtime handles the delivery.


Thanks for reading! Let's stay connected:

Top comments (13)

Collapse
 
ofri-peretz profile image
Ofri Peretz

The point about removeItem triggering a second storage event that has to be explicitly filtered is the one that catches developers. In code review I've seen that guard stripped out by someone who thought it was dead code, and the result is subtle — the handler fires twice on logout but only the first redirect matters, so it's invisible until it isn't. From a static analysis perspective, the localStorage pattern is also genuinely difficult to lint: you have to track the sentinel key name, correlate the cleanup call, and flag JSON.parse without a try-catch. BroadcastChannel collapses all of that into something tools can reason about trivially. Worth calling out that AI assistants still generate the localStorage version almost by default, probably because it dominated StackOverflow for the entire decade before BroadcastChannel had reliable cross-browser support.

Collapse
 
parsajiravand profile image
Parsa Jiravand

Absolutely agree. That removeItem detail is especially easy to dismiss during a refactor because the duplicate event can look harmless until the handler has side effects.

The static-analysis point is interesting too. With localStorage, you have to infer quite a lot of intent from loosely connected operations: the key, the sentinel value, cleanup, and potentially unsafe parsing. BroadcastChannel makes the communication model much more explicit, which is much easier for both humans and tooling to reason about.

And yes, I think the StackOverflow history is a big reason the localStorage pattern keeps showing up in generated code. It's familiar, widely documented, and used to be the practical option. Now that BroadcastChannel is broadly available, it's worth reconsidering that default.

Collapse
 
ofri-peretz profile image
Ofri Peretz

The static-analysis point matches something I hit maintaining lint rules for this exact pattern: a postMessage/removeItem cross-tab sync bug is nearly undetectable with pure AST analysis because the "message" is two independent statements (a write, then a delete) correlated only by a shared string key and timing, so a rule has to track key-name equality across otherwise-unrelated call sites to flag it, and that breaks the moment the key is built dynamically. BroadcastChannel.postMessage collapses that into one call with a typed payload at a real API boundary, so a rule just checks the argument shape instead of reconstructing intent from convention. It doesn't fix bad handler logic, but it removes an entire class of false negatives by construction rather than by discipline.

Thread Thread
 
parsajiravand profile image
Parsa Jiravand

That's a really interesting perspective, and I think "by construction rather than by discipline" captures the advantage perfectly.

With the localStorage approach, the synchronization contract is implicit—you have to reconstruct intent from a write, a delete, a shared key, and the timing between them. Once the key becomes dynamic, a static analyzer has very little reliable information to work with.

BroadcastChannel makes that contract explicit: there's a real communication boundary and a payload passed through a dedicated API. That doesn't prevent incorrect handler logic, but it moves a whole class of synchronization bugs from "something a linter has to infer" to something that's much easier to validate directly.

That's a benefit I hadn't emphasized enough in the article. Thanks for adding the static-analysis perspective.

Thread Thread
 
ofri-peretz profile image
Ofri Peretz

Right that the dynamic key is what actually kills static tracing — once it's sync:${tabId}, taint tracking loses the write/read pairing and you're left auditing string literals. Worth naming one thing though: even with a static key, localStorage forces JSON.stringify/parse at the boundary, so type information is lost twice — once in serialization, once in the runtime-string event payload. I've seen writer and reader drift on shape silently, a Date field becomes a string, a new field gets read but never typed, with zero compile-time signal. BroadcastChannel's structured clone through postMessage keeps that type information intact, so a generic on the channel actually holds across tabs instead of being a comment you have to trust.

Collapse
 
edmundsparrow profile image
Ekong Ikpe

Nice writeup — BroadcastChannel is a solid upgrade over the localStorage hack. Worth noting there's an even more robust layer above it for apps with real cross-tab state: a SharedWorker acting as a live process registry. Instead of every tab independently broadcasting and listening, tabs register with a single shared worker over MessagePort, which holds one authoritative in-memory state and pushes updates out. You get one source of truth instead of N tabs racing to agree, plus you can add IndexedDB as a cold-boot snapshot for when all tabs close and the worker itself restarts. BroadcastChannel is great for simple fire-and-forget events; SharedWorker is the move once you need actual orchestration (who's alive, who owns what, ordered messaging) across tabs.

Currently using this pattern in GnokeStation 2 (a browser-native OS shell) — SharedWorker as the kernel, tabs as processes registering with a pid/appId. Works well in practice.

Collapse
 
parsajiravand profile image
Parsa Jiravand

That's a great distinction. I see BroadcastChannel and SharedWorker as solving slightly different levels of the problem.

For simple cross-tab events—logout, cache invalidation, notifications, etc.—BroadcastChannel is usually enough and keeps the architecture lightweight. But once tabs need registration, lifecycle tracking, ownership, ordering, or a single authoritative state, the SharedWorker approach becomes much more compelling.

The "tabs as processes, SharedWorker as the kernel" model is a particularly interesting way to think about it. Adding IndexedDB as a cold-boot snapshot also makes the architecture much more resilient than treating each tab as an independent state holder.

Thanks for sharing the GnokeStation 2 example. That's a great real-world case of where the simpler BroadcastChannel pattern eventually needs another layer of orchestration.

Collapse
 
mudassirworks profile image
Mudassir Khan

the 'every part is a load bearing workaround' line is the best framing i've seen for why the storage event pattern is debt, not a solution.

we hit the double fire in a Next.js auth flow — setItem then removeItem was triggering our logout handler twice, and the timestamp dedupe we added broke in CI when the clock skewed by a millisecond. switched to BroadcastChannel and removed 35 lines of defensive glue.

one thing we added: a version field per message. when the auth payload shape changed mid deploy, old and new tabs briefly coexisted on different schemas.

does BroadcastChannel work inside service workers on the same origin, or is that a separate context?

Collapse
 
parsajiravand profile image
Parsa Jiravand

That's a great addition. I really like the version field idea, especially for auth flows where old and new tabs can genuinely coexist during a deployment. BroadcastChannel solves the transport problem, but the payload still needs a contract.

Regarding Service Workers: yes, BroadcastChannel is available in ServiceWorkerGlobalScope as well, so a service worker can participate in the same-origin channel and communicate with other compatible contexts. The important distinction is that it doesn't turn the channel into a durable message queue—messages are still only delivered to contexts that are listening at the time.

And removing 35 lines of defensive glue is a pretty convincing real-world result 😄. Thanks for sharing the deployment/versioning angle!

Collapse
 
leviyi profile image
leviyi

the localStorage hack always felt like a fragile side-channel — the removeItem firing a second event you then have to filter out is exactly the kind of thing that bites you six months later. BroadcastChannel reads like what the code was trying to be all along.

Collapse
 
parsajiravand profile image
Parsa Jiravand

Exactly. That's how I felt about it too—the localStorage approach works, but a lot of the code ends up being there to compensate for the behavior of the storage API rather than to implement the actual communication.

BroadcastChannel makes the intent much clearer: "send this event to the other contexts." Fewer conventions, fewer edge cases to remember, and much less defensive code around the transport itself.

Collapse
 
jeremy_6a02b3 profile image
Jeremy II

One thing the localStorage hack does that BroadcastChannel doesn't: the value sticks around. A tab opened after the logout broadcast never hears it, so you still need stored state for the startup read, and the channel only covers tabs alive at send time. Worth flagging for anyone about to swap it out one for one.

Collapse
 
parsajiravand profile image
Parsa Jiravand

Absolutely—that's an important distinction and probably the biggest thing to keep in mind when replacing the localStorage pattern.

BroadcastChannel is a communication mechanism, not persistent state. A newly opened tab won't receive an event that was broadcast before it existed, so if the state needs to survive tab lifecycles, you still need a persistent source of truth such as localStorage, IndexedDB, or server state.

I think the best architecture is often to use both: persistent storage for the current state and BroadcastChannel for notifying already-open contexts that the state changed. Thanks for calling this out!