Guide

Broadcast Channel API explained

Harbor Commerce support tickets spiked every holiday: shoppers opened the storefront in three tabs, added items in one, checked out in another, and wondered why the cart badge still read zero. The team had tried syncing through localStorage plus storage events, but that pattern only fires in other tabs — never in the tab that wrote the value — and stringifying the whole cart on every quantity click caused jank. Refactoring to the Broadcast Channel API let every same-origin document subscribe to a named channel, postMessage() structured cart deltas, and update UI within a frame. Logout broadcasts now reach all tabs in under 50 ms; duplicate checkout attempts dropped 34% in A/B. This guide covers channel lifecycle, message cloning rules, namespacing and security, pairing with service workers and browser storage, the Harbor Commerce cart refactor, a technique decision table against Web Workers and server push, pitfalls, and a production checklist.

What Broadcast Channel solves

Modern users keep multiple tabs, windows, and PWA instances open on the same site. Each tab has its own JavaScript heap — variables are not shared. You need a lightweight bus when tabs must agree on session state (cart, auth, feature flags, "new version available" banners) without round-tripping a server on every click.

Same origin, many documents

BroadcastChannel is a same-origin, same-browser profile primitive. Any document (top-level page, iframe, worker) that constructs new BroadcastChannel("name") with the identical string joins the same logical channel. When one participant calls channel.postMessage(data), every other participant's message event fires — including workers and service workers that have subscribed. The sender also receives its own message unless you filter by a client-generated tabId in the payload.

Compared to window.postMessage

window.postMessage requires a direct reference to another Window object (opener, iframe parent, or a captured popup). That breaks down with arbitrary tabs opened from bookmarks or middle-clicks. Broadcast Channel is reference-free: channel name is the rendezvous key.

API surface and lifecycle

Construction is one line:

const channel = new BroadcastChannel("harbor-cart-v2");

channel.onmessage = (event) => {
  const { type, payload, tabId } = event.data;
  if (tabId === myTabId) return; // ignore self if desired
  if (type === "CART_PATCH") applyCartPatch(payload);
  if (type === "LOGOUT") clearSessionAndRedirect();
};

channel.postMessage({
  type: "CART_PATCH",
  tabId: myTabId,
  payload: { sku: "WIDGET-12", qty: 2 }
});

// on page unload or SPA teardown:
channel.close();

Events and properties

  • name — read-only channel string passed to the constructor.
  • onmessage / addEventListener("message") — receives MessageEvent with data cloned via the structured clone algorithm.
  • onmessageerror — fires when deserialization fails (rare unless you pass non-cloneable types).
  • postMessage(data) — synchronously queues delivery to other contexts; delivery is async and ordered per channel.
  • close() — detaches this context; other participants stay connected.

There is no built-in request/response or acknowledgment. If you need RPC semantics, add correlation IDs in the payload and reply on the same or a dedicated ack channel.

Structured clone limits

Messages are copied, not shared. Supported types mirror postMessage elsewhere: plain objects, arrays, typed arrays, Date, Map, Set, ArrayBuffer, and cyclic graphs. You cannot send functions, DOM nodes, or symbols. Large payloads clone on the main thread — keep messages small (deltas, not full cart snapshots). For bulk data, write to IndexedDB and broadcast a "key changed" notification.

Ordering and backpressure

The spec guarantees messages on one channel are delivered in posting order, but there is no flow control. A tight loop of postMessage calls can flood slower tabs. Batch updates with requestAnimationFrame or debounce high-frequency events (slider drags, live cursors).

Channel naming, security, and privacy

Channel names are global within an origin. Use versioned, feature-specific prefixes (harbor-cart-v2, harbor-auth-v1) so deploys can migrate without cross-talk. Never put secrets in the channel name — any script on your origin can subscribe.

  • Origin isolationhttps://evil.example cannot hear https://shop.example; subdomains are separate origins unless you use document.domain (deprecated) or a shared worker on a common parent (unusual).
  • Cross-site iframes — embedded third-party frames on your page do not receive your channels unless they are same-origin.
  • XSS impact — injected script can snoop or spoof broadcasts. Treat channel traffic like any other client-side state: sign out server-side, do not send PII you would not store in sessionStorage.
  • User visibility — messages are not exposed to DevTools network panel, but they are visible in memory profilers; do not rely on obscurity.

Harbor Commerce cart and session refactor

The commerce team split responsibilities: IndexedDB remains the durable cart source of truth (offline queue, 50 MB SKU metadata cache); Broadcast Channel carries ephemeral sync events.

  1. Tab identity — each load generates crypto.randomUUID() stored in sessionStorage as tabId.
  2. Cart patches — quantity changes write to IndexedDB, then postMessage({ type: "CART_PATCH", tabId, patch }). Receivers apply the patch idempotently by patch.revision.
  3. Logout — auth module posts LOGOUT; all tabs clear tokens, unregister idle service-worker caches, and redirect to /login.
  4. SW coordination — the active service worker listens on harbor-sw-v1; when a deploy activates, it broadcasts NEW_VERSION so tabs show a refresh toast without polling.
  5. Checkout lock — initiating checkout posts CHECKOUT_STARTED; sibling tabs disable the pay button to prevent double submission while payment redirects.

Median cart-badge sync latency fell from 400 ms (storage polling fallback) to 12 ms. Support tickets mentioning "wrong cart" dropped one-third over six weeks. Safari 15.4+ and Chromium-family browsers cover >96% of Harbor traffic; legacy browsers fall back to storage events only.

Technique decision table

Approach Best for Latency Trade-offs
Broadcast Channel Multi-tab sync, logout fan-out, SW → tab signals Sub-50 ms typical Same origin only; no built-in persistence
localStorage + storage event Legacy Safari, tiny key-value flags Event-driven in other tabs only Writer tab must self-update; string size limits
SharedWorker Shared connection pool, centralized state machine Low once worker warm Safari added support late; harder debugging
window.postMessage Parent ↔ iframe, opener ↔ popup Immediate with reference No arbitrary tab discovery
Service Worker clients.matchAll Push from SW to controlled pages Depends on SW wake Requires SW registration; not tab-to-tab direct
WebSocket / SSE Cross-device, server-authoritative state Network RTT Infra cost; overkill for same-browser tabs
Server poll / refetch Source of truth on server, infrequent changes Seconds Simple but wasteful for carts

Common pitfalls

  • Forgetting close() — SPA route changes that recreate channels leak listeners; bind lifecycle to layout mount/unmount.
  • Infinite echo loops — tab A updates storage, tab B broadcasts, tab A applies and rebroadcasts; use revision numbers or ignore self-tabId.
  • Large payloads — posting a 2 MB cart JSON blocks the main thread during clone; send diffs only.
  • Assuming delivery — no ack means a crashed tab misses messages; reconcile from IndexedDB or server on focus.
  • Channel name collisions — two features sharing "app" corrupt each other; namespace per feature and version.
  • Testing gaps — Playwright opens isolated contexts; use two pages in one context or mock the API in unit tests.
  • Replacing server validation — tab sync is UX; checkout totals must still be verified server-side.
  • Private mode quirks — some browsers partition storage aggressively; test cart sync in Safari private windows.

Production checklist

  • Define versioned channel names per feature (app-cart-v2).
  • Include tabId and monotonic revision in every payload.
  • Keep messages under a few kilobytes; persist bulk state in IndexedDB.
  • Call channel.close() on teardown and before creating duplicates.
  • Provide storage-event fallback for browsers without Broadcast Channel.
  • Wire service-worker NEW_VERSION broadcasts into a user-visible refresh prompt.
  • Log out all tabs on password change and token revocation server-side.
  • Integration-test two-tab cart add, remove, and checkout lock flows.
  • Document message schema in TypeScript types shared across tabs and SW.
  • Monitor support metrics for "stale cart" after deploys.

Key takeaways

  • Broadcast Channel is the standard way for same-origin tabs, workers, and service workers to exchange messages without holding window references.
  • Payloads are structured-cloned copies — send small deltas, persist durable state elsewhere.
  • Harbor Commerce cut cart-sync latency and duplicate checkouts by pairing IndexedDB persistence with versioned broadcast events.
  • Choose Broadcast Channel for in-browser fan-out; use WebSockets when devices or users must stay aligned across machines.
  • Namespace channels, close on teardown, and never treat client broadcasts as authoritative for payments or auth.

Related reading