Home / Articles / Sync a Shopping Cart Across Browser Tabs: BroadcastChannel vs localStorage

This article is published in English.

Sync a Shopping Cart Across Browser Tabs: BroadcastChannel vs localStorage

Why localStorage storage events drop identical payloads, why Date.now fixes are flaky, and how BroadcastChannel plus a durable store solves cross-tab cart badges.

1658 words

Cross-tab cart badges look like a five-minute task until a QA ticket proves the first idea silently drops messages. The walkthrough below follows a common interview scenario: same-origin tabs, no shared memory, no server ping—only browser platform tools.

Scenario

A shopper keeps two product tabs open. They add an item in tab A. Tab B’s header badge should catch up. Otherwise tab B still shows the old count and the shopper assumes the add failed.

Constraints from the interviewer: tabs cannot share JavaScript heaps, and a network round trip is disallowed for the signal itself. The platform must carry the notice.

First attempt: storage events

Most candidates reach for localStorage plus the storage listener. A write on one tab notifies peers on the origin.

// Tab that adds the item
function addToCart(sku) {
  cart.add(sku);
  renderBadge();
  localStorage.setItem('cart-sync', JSON.stringify({
    type: 'CART_ADD', sku, qty: 1
  }));
}
// Every other tab
addEventListener('storage', (e) => {
  if (e.key !== 'cart-sync') return;
  const msg = JSON.parse(e.newValue);
  if (msg.type === 'CART_ADD') { cart.add(msg.sku); renderBadge(); }
});

That sketch often demos cleanly. Then comes the ticket.

The QA report that breaks it

Reproduce: add the same SKU twice. Tab A shows quantity 2. Tab B stays at 1. Reloading tab B finally shows 2.

Nothing throws. Logs are quiet. The listener looks correct—so why did the peer miss an update?

The clue is that a refresh repairs the badge: durable state was right; only the live signal failed.

Root cause: equal values are ignored

The HTML setItem algorithm bails out when the incoming string matches what is already stored for that key—paraphrased from the spec as “if the previous value equals the new value, stop.” No disk write, no broadcast, no peer wake-up.

Identical cart actions can serialize to the same bytes:

{"type":"CART_ADD","sku":"SKU-1029","qty":1}

Tab A still paints locally after its own add. Tab B never receives a storage event. After reload, tab B rereads storage and looks fine—classic intermittent QA pain.

Why duplicates feel rare but are not

Sequences that alternate values (A then B then A) keep working. Pain appears on identical consecutive payloads:

  • Double-clicks that add the same SKU
  • Heartbeats that republish an unchanged "ONLINE" status
  • Repeated SESSION_EXPIRED notices while a slow tab is still booting

Those are exactly the moments peers most need a wake-up call.

Timestamp “uniqueness” is fragile

A common patch stuffs Date.now() into the JSON so strings differ:

localStorage.setItem('cart-sync', JSON.stringify({
  type: 'CART_ADD', sku, qty: 1,
  t: Date.now()          // force the value to differ
}));

Millisecond clocks collide when two writes land in the same ms. Whether the patch works then depends on scheduler luck—sometimes yes, sometimes no. Correctness that depends on wall-clock granularity is not correctness. Prefer crypto.randomUUID() (or another strong unique token) when forced to stay on storage.

Cleanup creates double delivery

Leaving unique payloads forever is messy, so people removeItem immediately after setItem. That emits two notifications: one for the write, one for the delete.

event 1 → { key:'cart-sync', oldValue: null,    newValue: '{"type":"CART_ADD",…}' }
event 2 → { key:'cart-sync', oldValue: payload, newValue: null }

Unless the handler ignores newValue === null, the peer applies the cart mutation twice. The storage-as-bus design now needs uniqueness, null guards, parsing, and cleanup—because the API is a key/value store that occasionally shouts, not a message queue.

Preferred tool: BroadcastChannel

When the job is messaging—not persisting—use the messaging API:

const bus = new BroadcastChannel('cart-sync');
// send — the same message, as many times as you like
bus.postMessage({ type: 'CART_ADD', sku, qty: 1 });// receive
bus.onmessage = (e) => {
  if (e.data.type === 'CART_ADD') { cart.add(e.data.sku); renderBadge(); }
};addEventListener('pagehide', () => bus.close());

Repeated identical objects still deliver. No equality short-circuit. Structured clone carries richer types than JSON (Date, Map, Set, typed arrays). Think of storage as a database with optional change shouts, and BroadcastChannel as a shout with no database.

Follow-up questions that matter in production

Self-delivery. The posting channel object does not receive its own message, but another channel instance on the same name in the same document does—and so can same-origin iframes. Deduplicate if multiple subscribers exist in one page.

Sync vs async. Delivery is queued on the receiver’s event loop (async). Cloning during postMessage is sync and rejects non-cloneable values immediately (DataCloneError for functions).

Late tabs. Channels do not replay history. A tab opened after the add still shows the old badge unless it reads a durable store. Pattern that ships:

// The shape that actually ships:
// durable store = the truth, channel = the doorbell
async function addToCart(sku) {
  await idbPut('cart', sku);                     // atomic, survives reloads
  bus.postMessage({ type: 'CART_CHANGED' });     // just a signal
}
bus.onmessage = async () => renderBadge(await idbCount('cart'));

Durable data is truth; the channel is only the doorbell announcing that truth changed.

Counter in storage. Read/modify/write across tabs loses updates; the platform offers no lock. Do not invent a distributed counter on localStorage.

Where storage still wins. Preferences such as theme or locale that must be read synchronously before paint and change rarely. Use storage events for values; use BroadcastChannel for events.

Self-check

With the naive equal-payload storage approach, two identical adds one second apart produce how many peer events?

localStorage.setItem('cart-sync', '{"sku":"SKU-1029"}');
// … one second later, same product added again …
localStorage.setItem('cart-sync', '{"sku":"SKU-1029"}');

Answer: a single event. Elapsed time is irrelevant; only a changed string triggers notification.

Interview wrap-up

Lead with BroadcastChannel for cross-tab signals, keep IndexedDB or similar as the cart source of truth, and cite the equal-value early return if storage-as-bus is proposed. Mention late-tab replay limits, multi-channel self-echo, and why lock-free counters in localStorage fail.

Takeaways

Cross-tab UI sync is a messaging problem wearing a storage costume. Treat durable state and notifications as separate layers, pick APIs that match each layer, and test identical consecutive actions—the case tutorials skip and production users hit with double-clicks.

Extra production notes

Mobile browsers may discard background tabs aggressively; a badge refresh on visibilitychange that rereads the durable store covers cases where a message was missed while frozen. Pair that with the channel for foreground peers.

Automated tests should drive two contexts (Playwright tabs) and assert both the equal-payload storage footgun and BroadcastChannel delivery of duplicates. Document the chosen durable key schema so a future server sync can merge without inventing a second source of truth.

Feature flags sometimes gate “live badge” behavior. Keep the durable write unconditional; only the doorbell may be optional. Otherwise a flag-off tab diverges permanently from flag-on peers.

Security-wise, never put auth tokens in localStorage messages. Cart SKUs are fine; session secrets are not. Prefer posting opaque ids and letting each tab read privileged details from httpOnly channels or memory after a validated session check.

If the storefront spans multiple subdomains, BroadcastChannel will not cross them. Options include a first-party shared worker on a common parent domain, or server-sent events keyed by cart id. Call that limitation out early in design reviews.

Internationalization of the badge (plural rules) should run in each tab after reading the count—do not broadcast preformatted strings unless every locale is guaranteed identical.

Finally, measure: log how often duplicate SKU adds occur in analytics. If the rate is non-trivial, the equal-value storage trap would have been a latent production incident waiting for the first multi-tab power user.

Mapping the interview to a design doc

When writing this up for a team, separate three decisions: (1) which durable store holds cart lines, (2) which transport wakes peer tabs, and (3) how the badge reducer interprets doorbell messages. Collapsing those decisions into “just use localStorage” is what creates the equality trap.

A short design doc can include a sequence diagram: user click → mutate IndexedDB → postMessage on BroadcastChannel → peers invalidate badge query. Note failure modes: channel unsupported (rare in modern evergreen browsers, but check), private mode quirks, and multi-profile browsers isolating storage.

Testing checklist

  • Double-add identical SKU with storage-only doorbell (expect miss)
  • Double-add with BroadcastChannel (expect two updates)
  • Open a third tab after adds (expect correct count from durable read, not from replay)
  • Rapid adds inside one millisecond with timestamp uniqueness (expect intermittent miss)
  • setItem + removeItem without null guard (expect double count)

Automating that checklist in CI prevents regressions when someone “simplifies” back to storage events.

Why interviewers like this prompt

It rewards reading specs, not memorizing API names. Candidates who have only skimmed MDN miss the equality return. Candidates who have shipped multi-tab UIs mention BroadcastChannel and durable stores without being steered. The follow-ups about late tabs and lock-free counters reveal whether the answer was a blog-post fragment or lived experience.

For take-home variants, ask for a tiny demo repository with two routes and a README describing the chosen layers. Reviewers should open two windows and click—manual proof beats a paragraph of theory.

Related patterns

Presence indicators, collaborative cursors (lightweight), and logout fan-out use the same doorbell model. Collaborative document editing usually needs CRDTs or a server; do not stretch BroadcastChannel into a consistency protocol. Keep the cart example honest: eventual badge sync, authoritative durable cart, optional server reconciliation later.

Keep the story boring in production: one durable cart, one doorbell, explicit tests for duplicate actions, and no clever reliance on millisecond clocks. That boring shape is what keeps multi-tab badges honest when shoppers open more windows than the happy-path demo ever did.

That combination is enough. Done.