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
MessageEventwithdatacloned 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 isolation —
https://evil.examplecannot hearhttps://shop.example; subdomains are separate origins unless you usedocument.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.
- Tab identity — each load generates
crypto.randomUUID()stored insessionStorageastabId. - Cart patches — quantity changes write to IndexedDB,
then
postMessage({ type: "CART_PATCH", tabId, patch }). Receivers apply the patch idempotently bypatch.revision. - Logout — auth module posts
LOGOUT; all tabs clear tokens, unregister idle service-worker caches, and redirect to/login. - SW coordination — the active
service worker
listens on
harbor-sw-v1; when a deploy activates, it broadcastsNEW_VERSIONso tabs show a refresh toast without polling. - 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
tabIdand monotonicrevisionin 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_VERSIONbroadcasts 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
- Service workers and PWAs explained — SW lifecycle and client messaging
- Browser storage explained — cookies, localStorage, and IndexedDB
- Web Workers explained — off-main-thread compute and worker messaging
- WebSockets and SSE explained — server-push when tabs are not enough