Seed Protocol
Concepts

Multi-tab behavior

How browser tabs of one app share a single SQLite database and OPFS tree: the locks every tab takes, the leader tab that runs background work, how other tabs stay current, the multiTab modes, and the errors users can see.

Every tab of your app opens the same database and the same files. The SDK keeps that safe by taking turns on writes and electing one leader tab that applies every tab's item edits and runs background work, so two tabs never sync or publish the same thing twice. This is the default, multiTab: 'coordinate'; you don't need to set anything up.

One database, many tabs

In the browser the SDK stores everything in the browser's private file system (OPFS): the SQLite database at <filesDir>/db/seed.db and your files beside it. OPFS belongs to the origin, not the tab, so a second tab of the app opens the very same database rather than a copy.

Nothing diverges between tabs, then: there's one database file, and a write in one tab is a write for all of them. What can go wrong is two tabs doing the same work at once.

OPFS adds a constraint of its own. Fast synchronous access to an OPFS file goes through an access handle that only one context can hold at a time, so a file has a single writer at any moment. Each tab runs its own SQLite connection in a worker (through SQLocal), and SQLite's OPFS driver takes the handle only for as long as a statement or transaction needs it. The tabs take turns:

  • SQLite's own locking keeps the tabs' connections apart: a tab that finds the database busy gets SQLITE_BUSY or SQLITE_LOCKED.
  • The SDK then retries for about a second. A single statement is retried on its own; a transaction, such as one that applies an item edit, is retried as a whole. Either way, one that was refused didn't apply.
  • The file system layer (ZenFS) mounts the whole OPFS tree when the client starts, and skips entries another context is holding. If the mount itself keeps failing, init gives up with FileSystemLockedError (see Errors).

The coordination primitive is the browser's Web Locks API. Unlike a lock you'd keep in the database, a Web Lock belongs to the tab that holds it, and the browser releases it when the tab closes or crashes. No tab can leave a stale lock behind.

What every tab protects

Some steps are unsafe to run twice at once, whichever tab runs them. The SDK runs each under a named Web Lock, so tabs queue for them. All but seed:open:<db> apply in both modes:

WorkLockWaits up to
Database migrations at start-up, and recreating a database older than 0.7.7seed:migrate:<db>60 s, then TabLockTimeoutError
Init writes that check for rows and then insert them: config, addresses, schemas, model rowsseed:init:<db>60 s, then TabLockTimeoutError
An EAS sync run, and the model-type rewrite the leader runs after initseed:eas-sync:<db>No limit: a sync can take minutes
Copying an assigned File or Image value's bytes to the staging folderseed:staging:<path>60 s in the tab that copies them. The leader doesn't wait: it tries the write again 50 ms later
Applying an edit to an ItemStorage property's file and its rowseed:item-storage:<file path>Doesn't wait: the write is tried again 50 ms later, and other writes go ahead
Recording an Arweave transaction that no gateway could serveseed:excluded-transactions:<db>60 s, then TabLockTimeoutError
Publishing one item (@seedprotocol/publish)seed:publish:<seedLocalId>Doesn't wait: a second tab skips it
Writing a downloaded file to OPFSseed:opfs-write:<path>No limit
Keeping the database open, against a resetseed:open:<db>: each tab holds it shared from init until its database closes; client.resetLocalData() takes it exclusivelyInit waits for a reset in progress with no limit. A reset waits up to otherTabsTimeoutMs (10 s) for other tabs to let go, then deletes anyway

<db> is the database path, <filesDir>/db/seed.db. With multiTab: 'off' a tab doesn't take seed:open:<db>, so a reset doesn't wait for it.

So when two tabs start together, the second waits a moment for the first to migrate and write its init rows, then continues. Two syncs never interleave, which matters because synced rows are checked and then inserted. And a publish runs in exactly one tab: the one that started it, or after a reload the first tab to restore it.

The leader tab

Some work only needs to happen once per device, not once per tab: applying queued edits, pulling from EAS, downloading every synced file, and checking that uploads reached Arweave. With multiTab: 'coordinate', the tabs elect a leader to do it.

  • Election. When client.init starts, each tab asks for the Web Lock seed:leader:<db> and holds it until it closes or calls client.stop(), which hands leadership to the next tab. The first tab to ask leads. The others wait in the browser's queue.
  • Handover. When the leader closes or crashes, the browser grants the lock to the next tab in the queue, which becomes leader. Automatic syncs and the model-type rewrite that tab skipped while it wasn't leader run when it takes over, and it applies the edits still in the outbox, so closing the leader loses neither a catch-up sync nor a queued edit.

Only the leader runs:

  • Applying the outbox: every tab's item edits, in the order they were queued. Another tab queues its edit and tells the leader, and its save() resolves once the leader has applied the write.
  • Pruning: applied and dropped edits after a day and failed ones after a week (checked at most hourly), and change feed log rows after 10 minutes.
  • Automatic EAS syncs: the start-up sync and syncs after client.setAddresses. In another tab, setAddresses still saves the addresses and then asks the leader to sync them.
  • The bulk file downloads and image resizing that follow those syncs.
  • Rewriting stored model types to their EAS name, in the background after init, under the sync lock.
  • The Arweave finalize worker from @seedprotocol/publish, which polls every 45 seconds.

Every tab still runs:

  • Your reads and writes. Any tab can create and edit items; the leader applies the edits.
  • client.syncFromEas(). It runs in the tab that calls it, under the sync lock, so it may wait for a sync that's already running.
  • Publishes the tab starts.
  • On-demand file downloads for what that tab is showing.

isLeaderTab() tells you whether this tab leads, and whenLeaderTab() resolves when it does (at once if it already does). Before client.init has started coordination, every tab reports itself as leader.

When another tab resets

client.resetLocalData() deletes the database and files every tab shares. Before deleting, it tells the other tabs of the database to stop. A tab that hears it stops its client, as client.stop() would, with stopReason: 'reset' on the client snapshot. It doesn't reload: your app decides what that tab shows next, for example a message or a fresh client.init, which waits for the reset to finish and opens an empty database. In React, useGlobalServiceStatus() returns it as stopReason.

Keeping other tabs current

An edit from any tab lands in the shared database once the leader applies it, but each tab also keeps things in memory: loaded Item instances, a file system cache and its own event bus. The SDK refreshes those in several ways.

Live queries and the change feed see every tab's writes. SQLocal tells the other tabs which tables changed, so BaseDb.liveQuery, useLiveQuery and hooks built on them re-run when another tab writes. Loaded Item and ItemProperty instances hear about writes through the change feed, which rides on the same notice: each tab reads the new log rows, and only the objects whose rows were written read them again, so a value edited in one tab shows up in the others once it's applied. Those reads include edits still waiting in the outbox, from any tab: a tab that loads an item, or re-reads a property, while another tab's edit is queued shows that edit.

Shared-data events are relayed. The SDK's event bus belongs to one tab. Events that mean "the shared data changed" (a sync stored new rows, addresses were saved, local copies were removed, files were written) also go to the other tabs over a BroadcastChannel. Each receiving tab updates itself before emitting the event locally: it drops removed items from its cache (its loaded items have already read a sync's rows through the change feed), and refreshes changed files in its file cache before emitting fs.files.changed. A relayed event is never relayed again. Events: Across tabs lists the events and how they're batched.

Schema and model lists refresh too. When a schema or a model-to-schema link is added, the SDK posts on two more channels, which @seedprotocol/react's useSchemas and useModels listen to.

Left stale on purpose. Schema, Model and ModelProperty instances already loaded in a tab keep the definition they loaded. Only another tab's schema edits make them stale, and the schema's conflict check is the backstop for that (see Conflict detection).

Modes

multiTab is a client.init option:

ModeLeaderLocks in the table aboveEvents relayed to other tabs
'coordinate' (default)One, electedYesYes
'off'Every tab acts as leaderYes, except seed:open:<db>No

With 'off', every tab applies the outbox and runs automatic syncs, bulk downloads, the model-type rewrite and the finalize worker itself, as the SDK did before tab coordination existed. The locks still keep that from corrupting data: syncs take turns, publishes still run in one tab, and each queued edit is claimed inside the transaction that applies it, so it's applied once. Live queries and the change feed still see other tabs' writes, because that comes from SQLite, not the event relay.

Coordination needs the Web Locks API and BroadcastChannel. The client doesn't run in a browser without Web Locks, in either mode: client.init rejects with UnsupportedBrowserError before coordination starts. On Node there are no tabs: the process always leads. The mode is fixed the first time client.init runs on a page.

Errors and what users see

Two init errors mean another tab is in the way. SeedClientGate passes them to onInitError; React overview: Handling init errors shows a gate that tells the user to close their other tabs and offers a retry for both.

FileSystemLockedError

client.init mounts the OPFS tree. If another context is holding it, the SDK retries for about two seconds and then rejects with FileSystemLockedError (code: 'OPFS_LOCKED'). Its message asks the user to close other tabs of the app and retry. The usual cause is another tab or a worker that's still running, for example from a page that was just reloaded. A retry often succeeds once that context lets go.

TabLockTimeoutError

TabLockTimeoutError (code: 'TAB_LOCK_TIMEOUT') means a tab waited more than 60 seconds for one of the locks above; lockName says which. At init it's the migration or init lock, and client.init rejects with it. It points at a tab that's stuck. Closing the other tabs releases their locks.

A publish that's already running elsewhere

If the user starts publishing an item that another tab is already publishing, @seedprotocol/publish logs a warning and doesn't start a second process. The first tab carries on. item.publish() rejects in that case, with a message that the item is already publishing in this tab or another.

If that tab closes mid-publish, the publish stays in_progress, and the next page of the app to load resumes it from its last saved step; tabs already open don't pick it up. The exception is a publish that stopped while uploading its data items through the upload API or Turbo: the signed data items aren't saved, so the restore marks it interrupted and the user has to publish again.

What this means for your code

  • Leave multiTab at 'coordinate' unless you have a reason not to. 'off' repeats the same background work in every tab.
  • Expect background work in one tab only. A sync triggered in a follower tab runs in the leader, and its results reach the follower through the change feed, live queries and the relayed easSeedDataSyncedToDb event. An edit made in a follower is applied by the leader too.
  • Render from live queries or the Seed hooks, not from values you copied out once. They pick up other tabs' writes; a variable doesn't.
  • Use whenLeaderTab() for your own once-per-device work, such as a timer that calls client.syncFromEas(), so it doesn't run in every tab.
  • Reload schema instances before editing a schema if another tab may have changed it.
src/periodicSync.ts
import { client, whenLeaderTab } from '@seedprotocol/sdk'

// Call after client.init has resolved: before init, every tab counts as leader.
// The timer runs in one tab at a time; if the leader closes, the next tab takes over.
export async function startPeriodicSync(intervalMs = 5 * 60_000) {
  await whenLeaderTab()
  setInterval(() => {
    client.syncFromEas().catch((error) => console.warn('EAS sync failed', error))
  }, intervalMs)
}

On this page