Rxova
Skip to content

How sync works

This page opens the hood. You don’t need any of it to use the library — but when you want to reason about an edge case (“what if both tabs write at the same millisecond?”, “what does a brand-new tab actually see?”), this walkthrough gives you the machinery: version clocks, the late-joiner handshake, presence, and the cross-origin handshake.

A shared store is a replicated object: every tab holds a full copy, and every key carries a version — a pair [counter, clientId]. Writing bumps the counter and stamps the writer’s id:

state.count = 5; // count's version: [1, 'k3j9x2']
state.count = 6; // count's version: [2, 'k3j9x2']

The write is broadcast as a patch{ key, value, version } — and every peer that receives one applies exactly one rule:

Apply the patch only if its version is newer than mine: higher counter wins; equal counters break the tie by comparing client ids.

That rule (newer() in the API) is the entire conflict story. It’s last-writer-wins per key, with a deterministic tie-break so every replica picks the same winner. No coordinator, no election, no extra round trips.

This is the part that kills split brain, so it’s worth slowing down. Tab A (aaaaaa) and tab B (bbbbbb) both hold note at version [3, …], and both write before either patch arrives:

MomentTab ATab B
writenote = "from A"[4, aaaaaa]note = "from B"[4, bbbbbb]
receivegets [4, bbbbbb] — same counter, bbbbbb > aaaaaaapplies Bgets [4, aaaaaa] — tie, aaaaaa < bbbbbbkeeps B
result"from B""from B"

Both tabs picked the same winner without talking to each other. And a third tab receiving those two patches in either order also lands on "from B" — convergence never depends on delivery order.

BroadcastChannel has no history: a message posted before you subscribed is gone. So how does a freshly opened tab show the current count instead of 0?

Tab BTab ANew tab (joiner)Tab BTab ANew tab (joiner)merge: apply every entry whoseversion is newer than minehellohellosnapshot { state, versions }snapshot { state, versions }

On creation, a store broadcasts hello. Every existing peer answers with a snapshot — its full state plus all versions. The joiner runs each entry through the same newer() rule it uses for live patches, so receiving three overlapping snapshots is harmless: older entries simply lose.

And here’s the subtle part: your initial value registers at version [0, myId]. Anything a peer actually wrote has a counter ≥ 1 — and therefore beats it. Think about what that means: useSharedState('pay-status', 'idle') in a brand-new tab hydrates to 'processing' if that’s the truth out there. The initial value never stomps a real one, and the duplicate-tab payment bug dies right here — the late joiner’s Pay button renders disabled from its very first frame.

Presence answers “who else is here?” without any central registry:

  • On joining a bus, a client broadcasts hello; existing peers reply with a ping so the joiner learns about everyone immediately.
  • Every client pings every 2 seconds.
  • A peer silent for 5 seconds is pruned — that’s the crashed tab, which never gets to say goodbye.
  • A closing tab broadcasts bye on pagehide, so clean exits disappear instantly instead of timing out.
  • Any message from a peer — a state patch, an event — counts as a liveness signal, so busy tabs never flicker offline and chatty tabs pay no extra heartbeat cost.

Peers carry a kind ('tab' or 'worker', extensible), which is how the demo renders workers as square dots and how scope: 'tabs' knows which writes to ignore.

The window channel has a different problem: the child page may take seconds to load, and postMessage to a window that isn’t listening yet is silently lost. The solution is a retrying handshake with queueing on both sides:

Child window (payment page)Opener (shop)Child window (payment page)Opener (shop)posts before ready are queuedpage loads… connectToOpener()loop[every 250ms until acked]both sides flush their queueswindow.open(url + ?ue-cid=nonce)ready (cid)ready-ack (cid)msg: order detailsmsg: progressresult (resolves opener's promise)

Nothing you post() before the handshake is dropped — it waits in a queue and flushes on ready. If the handshake never completes (default 15s), both ready and result reject with HandshakeTimeoutError; if the user closes the window first, result rejects with WindowClosedError — detected by both a pagehide signal from the child and a 400ms window.closed poll, so even a crashed child is noticed promptly.

  • Latency: BroadcastChannel delivery is typically sub-millisecond to a few milliseconds; it is an async task, never synchronous.
  • Ordering: patches from one writer arrive in order; interleavings between writers are resolved by the clock, not by arrival order.
  • No echo: your own posts are never delivered back to you — a channel handler only ever fires for other clients’ events.
  • At-most-once: there is no ack or retry on the same-origin bus. If a tab is mid-refresh when an event fires, it misses it — use state, which re-hydrates, for anything that must survive that.
  • Security model — the four gates on every cross-origin message.
  • Limitations — the boundaries all of this machinery deliberately stays inside.
  • Testing — drive every mechanism on this page from a unit test with MemoryHub.