Skip to content

@kontsedal/olas-cross-tab ​

BroadcastChannel-backed cache sync for @kontsedal/olas-core. When the app in one tab writes to a query or invalidates it, every other tab of the same origin sees the same change. No tab re-fetches, nothing touches storage, and no request reaches the server. SPEC §13.2.

This is the in-memory sibling to @kontsedal/olas-persist. Persistence mirrors durable state on the storage event; this mirrors the (much larger) in-memory query cache that never touches disk. Both are independently opt-in.

Install ​

bash
pnpm add @kontsedal/olas-cross-tab @kontsedal/olas-core @preact/signals-core

30-second example ​

ts
import {
  bindQuery,
  createQuery,
  createRoot,
  defineController,
  defineQuery,
  queryEngine,
} from '@kontsedal/olas-core'
import { crossTabPlugin } from '@kontsedal/olas-cross-tab'

type User = { id: string; name: string }

// Opt the query in. Its `id` routes messages between tabs, so it must be
// the same in every tab's build.
const userQuery = defineQuery({
  id: 'app/user',
  key: (id: string) => ['user', id],
  fetcher: ({ signal }, id: string) =>
    fetch(`/api/user/${id}`, { signal }).then((r) => r.json() as Promise<User>),
  meta: { crossTab: true },
})

const appController = defineController((ctx) => ({
  user: createQuery(ctx, userQuery, () => ['me']),
  users: bindQuery(ctx, userQuery),
}))

const root = createRoot(appController, {
  queries: queryEngine(),
  deps: {},
  plugins: [crossTabPlugin({ channelName: 'my-app/cache/v1' })],
})

// Tab A:
root.api.users.write('me', (prev) => ({ id: 'me', ...prev, name: 'New' }))

Tab B's subscribers see the new value on the next signal flush. No fetch fires in Tab B.

API ​

ts
function crossTabPlugin(options: CrossTabOptions): OlasPlugin

type CrossTabOptions = {
  channelName: string
  onWarn?: (message: string, cause?: unknown) => void
  channelFactory?: (name: string) => ChannelLike | undefined
  maxPayloadBytes?: number
  optimistic?: boolean
  origins?: readonly string[]
  validate?: (queryId: string, data: unknown) => boolean
}
OptionDefaultWhat
channelNamerequiredName of the BroadcastChannel. Include a version suffix (my-app/v2) for clean cross-deploy isolation — receivers from a different deploy with a different channel name simply don't see each other's traffic.
onWarnconsole.warnCalled on non-fatal conditions: DataCloneError while broadcasting (the data isn't structured-cloneable), an oversized payload, a malformed inbound message, or one validate rejected.
channelFactorydefaultChannelFactory (wraps BroadcastChannel in a browser tab or worker)Override the channel constructor, for tests or a runtime the default skips. Return undefined to disable cross-tab (the plugin installs no hooks). See SSR and servers.
maxPayloadBytes512 * 1024Soft cap on one outbound message, estimated by its JSON length. Over the cap, the plugin warns and still posts. Infinity turns the warning off.
optimistictrueAlso mirror optimistic setData writes and their rollbacks, so peers show a pending edit before the server confirms it. A peer shows it as a guess of its own. With false, only canonical writes, commits and invalidations cross, and a canonical write made under a guess sends the data beneath it.
origins[]Origins whose writes and invalidations are mirrored too: a plugin's name, or the origin a bindQuery handle was given. See Whose writes cross.
validateaccept every payloadCheck a peer's data before this tab writes it. Return false to drop the message, which is reported through onWarn. A validate that throws rejects the message.

The plugin needs a query engine. A root created without queries: queryEngine() throws at createRoot, before any channel opens.

How it works ​

The plugin's onWrite and onInvalidate hooks post each mirrored change onto a BroadcastChannel. A receiving tab applies it through its own host.queries.write or host.queries.invalidate. That write carries the plugin's name, 'olas-cross-tab', as its origin. The send gate skips a write with that origin, so nothing echoes back.

Tab A: users.write(...) → cache write (origin: undefined) → plugin onWrite
                                                                 ↓
                                                        channel.postMessage(msg)
                                                                 ↓
                        ━━━━━━━━━━━━━━━━━━━━━ BroadcastChannel ━━━━━━━━━━━━━━━━━━━━━
                                                                 ↓
Tab B: channel listener → validate(queryId, data) → host.queries.write(...)
       cache write (origin: 'olas-cross-tab') → plugin onWrite → not mirrored

What crosses ​

ChangeCrosses?
write and replace (canonical)Yes. A replace is applied as a replace, so it supersedes a fetch the receiving tab has in flight.
setData (optimistic) and its rollbackYes, unless optimistic: false. The receiving tab shows the guess as a guess of its own.
A commit, when a mutation succeedsYes. The receiving tab ends on the committed value.
invalidateYes. The receiving tab marks the entry stale, and refetches it only if it has subscribers.
A fetch resultNo. Every tab runs its own fetcher, so rebroadcasting results would be noise that changes nobody's cache.
HydrationNo. It is a per-tab concern too.

A receiving tab applies a write only to an entry it already holds for that key, and creates no new entries. A subscriber that mounts later fetches as usual.

A peer's guess stays a guess ​

Each message carries the write's source, and the receiving tab applies it as what it is. A peer's optimistic write is shown through host.queries.setData, the way onMutate shows one. So in the receiving tab it restarts no stale clock, hasPendingMutations reads true, and persistQueryCachePlugin does not store it. The peer's rollback removes it, and the peer's commit makes the committed value the tab's own data, as a commit does: the stale clock stays where the server set it. A peer that closes before its mutation settles never sends either, so a guess its peer says nothing more about for 30 seconds is rolled back.

A canonical write made while the sender shows a guess carries the data beneath the guess too. The receiving tab writes that as server truth, and shows the rest as the peer's guess. A message from a version before 1.0 carries no source, and is applied as a write.

Whose writes cross ​

By default the plugin mirrors only the app's own writes: those whose origin is undefined. A write with an origin came from another plugin, or from a handle made with bindQuery(ctx, query, { origin }). Such a write is usually derived. A realtime push reaches every tab itself, so mirroring it would deliver it twice.

List an origin in origins to mirror its writes too. The entities plugin is the usual case: an entities.update(...) patch stays in its own tab until you opt in.

The entities default is opt-in for two reasons:

  • An app write that crosses already reaches the peer's store. The peer's own entities plugin walks the mirrored write, as it walks any other write.
  • An update that every tab makes for itself, such as one realtime push each tab folds into its store, would cross from every tab. With two tabs that is two messages and a second, redundant write in each tab, where the default sends none.

Opt in when one tab's UI makes the update and the other tabs have no other way to learn it.

ts
import { crossTabPlugin } from '@kontsedal/olas-cross-tab'
import { ENTITIES_PLUGIN_NAME } from '@kontsedal/olas-entities'

const crossTab = crossTabPlugin({
  channelName: 'my-app/cache/v1',
  origins: [ENTITIES_PLUGIN_NAME],
})

A write the plugin applied from a peer is not mirrored back, even with the plugin's own name in origins.

Echo prevention (three layers) ​

  1. Origin: a write the plugin applied from a peer carries origin: 'olas-cross-tab', and the send side skips it.
  2. Own-source drop: receivers filter messages by sourceId. Every root picks a random one when the plugin sets up. If the transport echoes the message back, the sender ignores it.
  3. (sourceId, msgId) dedup: monotonic msgId per sourceId lets receivers drop out-of-order or duplicate messages. A receiver moves a peer's cursor only for a message it applied.

Protocol versioning ​

Messages carry v: PROTOCOL_VERSION. Receivers drop messages with a v they don't understand. Channel names themselves are user-supplied; for cross-deploy isolation, embed a version in your channelName (e.g. 'app/cache/v2').

Non-cloneable data ​

BroadcastChannel uses structured clone. Cache data containing a function or a symbol throws DataCloneError at postMessage. A class instance does not throw; it arrives as a plain object without its prototype. The plugin catches the throw, calls onWarn(...), and drops the message. The sender's cache is unaffected — only the cross-tab echo is lost.

Messages from other scripts ​

Any same-origin script can post on the channel, so a receiving tab treats a message as possibly corrupt (SPEC §22):

  • It drops a message whose protocol version, sourceId or msgId is wrong. A msgId has to be a safe non-negative integer.
  • It moves a peer's cursor only for a message it applied. A forged message with a huge msgId under a real peer's sourceId therefore moves nothing unless it is one the tab would apply. If it is, the real peer's next message lands far below the cursor. The receiver takes that as a wrong cursor, applies the message and restarts the cursor from it. One planted message cannot silence a peer.
  • It warns about a message with a bad queryId, keyArgs or pageParams, and does not apply it.
  • It reports a message it cannot apply, such as a key the engine cannot hash, through onWarn. Nothing throws out of the channel's handler.
  • It passes each payload to validate, when one is given.

Olas does not check that a peer's data matches the query's type. validate and a versioned channelName are the tools for that:

ts
import { crossTabPlugin } from '@kontsedal/olas-cross-tab'

const crossTab = crossTabPlugin({
  channelName: 'my-app/cache/v1',
  validate: (queryId, data) =>
    queryId !== 'app/user' ||
    (typeof data === 'object' && data !== null && typeof (data as { name?: unknown }).name === 'string'),
})

Devtools ​

In a development build, the plugin reports every message on its lane in @kontsedal/olas-devtools, through host.debug. A tab reports each message it posts, and each message a peer sent on this protocol version:

ts
{ kind: 'send', type: 'setData', source: 'write', queryId: 'app/user', outcome: 'posted', from: 'lq3k-7f2a', msgId: 4, key: ['user', 'me'] }
{ kind: 'receive', type: 'setData', source: 'write', queryId: 'app/user', outcome: 'applied', from: 'lq3k-7f2a', msgId: 4, key: ['user', 'me'] }

from is the sending root's sourceId, so from and msgId name one message in the sender's lane and in every receiver's.

outcomeMeaning
postedSent.
not-cloneablepostMessage threw, and the message was dropped.
appliedWritten or invalidated in this tab.
duplicateIts msgId is at most the last one this tab applied from that peer, and less than 64 below it.
malformedA field has the wrong shape, or the message type is unknown.
ignoredThis tab has not bound the query, or has not opted it in.
rejectedvalidate returned false or threw.
failedApplying it threw, such as on a key the engine cannot hash.

A payload that is not an object, a message on another protocol version, and a tab's own echoed message are dropped without a lane event. The default build strips the calls.

Per-query opt-in ​

Two fields on the definition gate cross-tab behavior:

  • id: string — required on every defineQuery and defineInfiniteQuery. Messages route by it, so keep it stable and identical across tabs and deploys. Write it by hand: a name derived from fetcher.name changes under minification.
  • meta: { crossTab: true } — the per-query gate. The package adds crossTab to core's QueryMeta type. Without it, the plugin doesn't broadcast, so module-internal queries don't leak. The gate applies on both send and receive. A tab ignores inbound writes for queries its own build didn't opt in, so an opt-in mismatch across deploys can't push writes into unmarked queries.

Infinite queries opt in the same way. Their pages travel with their pageParams, so the receiving tab keeps paging from them. Page arrays can be large, and maxPayloadBytes warns about them.

SSR and servers ​

Without a channelFactory, the plugin opens a channel only in a browser tab (a scope with a document) or a web worker. Everywhere else it installs no hooks, the root still constructs, and cross-tab is off. So you can wire the plugin unconditionally in code the server and the browser share.

Servers are the reason for the rule. Node, Bun and Deno all define a global BroadcastChannel, and there it reaches every root in the process, plus other worker threads or isolates. A server that builds a root per request would put one user's writes into another user's render. The default therefore rules out Node, Bun and Deno, including their workers.

A server that installs a global document, such as through global-jsdom, looks like a browser tab to this check. Pass channelFactory: () => undefined there.

To open a channel where the default does not, pass a factory. BroadcastChannel fits ChannelLike as it is:

ts
import { crossTabPlugin } from '@kontsedal/olas-cross-tab'

const crossTab = crossTabPlugin({
  channelName: 'my-app/cache/v1',
  channelFactory: (name) => new BroadcastChannel(name),
})

Interaction with @kontsedal/olas-persist ​

These two layers solve different problems:

  • @kontsedal/olas-persist mirrors durable state via localStorage + the storage event.
  • @kontsedal/olas-cross-tab mirrors the in-memory query cache via BroadcastChannel.

You can combine them on the same logical entity, but it's redundant — @kontsedal/olas-persist's cross-tab sync already covers the durable copy.

Conflict model — last-delivery-wins ​

Cross-tab sync is a broadcast, not a consensus protocol — think of it as "every tab refetched, but for free," not as a source of truth. There's no arbitration, no vector clocks, no server round-trip: each tab applies inbound writes in delivery order, and the last delivery wins for that tab. So two tabs that write the same entry concurrently can settle on different values until something reconciles them.

That something is a server refetch, and it's a one-liner: after a write that matters, call query.invalidate(...) (it broadcasts too), and every tab pulls authoritative server truth and re-converges. The mutation onError and onSuccess → invalidate pattern gives you this for free.

Limitations ​

  • No structural diffs. Every write broadcasts the full post-update value. For chunky cache entries this is fine because BroadcastChannel is in-memory; for very large arrays it's a known cost.
  • No pending-mutation arbitration. If two tabs run optimistic mutations on the same entry concurrently, the last write to arrive wins on both sides. Your mutation onError and onSuccess then re-syncs from the server, which restores convergence at the cost of a temporary divergence.
  • Optimistic writes cross tabs by default. Optimistic state and its rollback are visible in other tabs, as guesses. Pass optimistic: false to keep them local.
  • A peer's guess can expire before its mutation settles. It is rolled back after 30 seconds without a word from the peer. A mutation that takes longer shows its commit in the receiving tab when it lands.

Further reading ​

Released under the MIT License.