DEV Community

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

Posted on

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

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 (1)

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.