Rxova
Skip to content

Compared to the alternatives

There is a real question behind “should I use this”, and it is not “is it good”. It is what would I write instead, and what would that cost me. So: the alternatives, what each is actually good at, and where this library is the wrong answer.

vs. writing it yourself on BroadcastChannel

Section titled “vs. writing it yourself on BroadcastChannel”

This is the honest baseline, and for a surprising number of apps it is the right one. Two tabs, one flag, no conflicts:

const channel = new BroadcastChannel('theme');
channel.postMessage(theme);
channel.onmessage = (event) => setTheme(event.data);

That is twelve lines and no dependency. Ship it.

What it does not survive is the second week:

  • Two tabs write at once. Last message wins, but “last” is delivery order, which differs per tab. They diverge, and nothing tells you.
  • A tab opens later. It starts at its own initial value and stays wrong until someone writes again — there is no state, only events.
  • A tab is backgrounded. Timers are clamped to once a minute; anything you built on an interval starts flapping.
  • A tab crashes while holding the “I own the websocket” flag. Nothing hands it over. (Web Locks does this correctly, and is a different API to learn.)
  • A tab is restored from bfcache. It missed everything while frozen and does not know it.
  • A value stops being cloneable — someone puts a function on the object — and the write throws in one tab and not the others.

That list is the library. It costs about 1.8× a raw post and roughly 4 kB for the store.

Use the platform directly when: you are sending fire-and-forget events with no state to converge, or you have exactly one writer.

The incumbent, and genuinely battle-tested — RxDB runs on it. It is a pipe with excellent fallbacks: where BroadcastChannel does not exist, it reaches for IndexedDB or localStorage, and it ships a leader-election module.

Where the two differ:

broadcast-channeluse-everywhere
ModelPub/sub onlyPub/sub and a state model with per-key version clocks
Late joinersNothing — you missed ithello/snapshot hydration
TypesUntyped messagesTyped message maps, optional schema validation
ReactNoneThe hooks are the primary API
Leader electionPre-Web-LocksWeb Locks where available, heartbeat where not
Cross-originNot its problemHardened window channel (origin, nonce, source, COOP)
FallbacksIndexedDB and localStoragestorage event, and it says so loudly

Use broadcast-channel when: you need the IndexedDB fallback specifically — an environment where BroadcastChannel and localStorage are both gone — or you want a pipe and nothing else, in a codebase with no React.

Not a competitor: a different axis. Zustand is how you hold state in a page. This is how state gets between pages. Plenty of apps have both — a Zustand store for the app, one shared key for the thing that has to be the same everywhere.

The tab-sync middlewares (shared-zustand, zustand-sync-tabs, and the storage-event persist recipes) are the comparable thing, and the difference is granularity. They broadcast the whole store, so two tabs writing different keys at the same time still conflict, and the loser’s write disappears. Per-key clocks are the entire point of the store here.

Use a Zustand middleware when: you already have Zustand, one key needs syncing, and no two tabs will ever write concurrently.

If all you want is “exactly one tab does this”, navigator.locks is excellent and you should use it:

navigator.locks.request('poller', () => forever());

The library’s leader election is Web Locks where the API exists. What it adds is a fallback for where it does not (Web Locks needs a secure context — an intranet app on plain http has none), a React binding that re-renders on handover, and the seat being on the same bus as your state and presence, so “who is leading” is answerable from any tab.

Use Web Locks directly when: leadership is all you need and you control the deployment origin.

A different problem, and worth being blunt about: this is not a CRDT and does not want to be.

Those solve concurrent editing of shared documents — two people typing in one paragraph, merged intent-preservingly, usually across a network. If you are building a collaborative editor, use one of them.

This library solves one user, several tabs, one origin. Last-writer-wins with version clocks is the right model for “which theme, which cart, which auth state”, and the wrong model for “which characters are in this paragraph”. The ceiling here is useSharedReducer, which orders commutative operations through the leader so a counter cannot lose increments. Past that, reach for a CRDT.

Use a CRDT when: two writers edit the same structure and both edits must survive.

vs. server-side state (TanStack Query, and friends)

Section titled “vs. server-side state (TanStack Query, and friends)”

If the state already lives on a server and tabs merely cache it, sync it there: a websocket, or query invalidation, and every tab converges by refetching. TanStack Query’s broadcastQueryClient does exactly this for its cache, and it is the right tool for server state.

What has no server to go to is client state: which tab owns the socket, who is looking at this record right now, the wizard step you are on, whether you are logged out. Those never leave the browser, and a round trip to prove two tabs agree about a local fact is an odd way to spend a network.

Use the server when: the data is the server’s anyway. Use this when: a round trip would be the only reason a server was involved.

Reach for use-everywhere when more than one tab writes, when a tab that arrives late has to catch up, or when a tab going away has to be noticed — crash included. Those three are the hard 20% everyone else rediscovers by shipping bugs.

Anything else, use the smaller thing.