Local-first and offline
Why the local database is your app's source of truth, what works without a network and what doesn't, how EAS sync brings published data in, and how publishing survives a reload.
A Seed app reads and writes a database on the user's device, not a server. The network only matters at the edges: pulling published data in from EAS, and pushing the user's content out when they publish.
The local database is the source of truth
When the client starts, it opens a SQLite database at <filesDir>/db/seed.db and a file store next to it. In the browser that's SQLite compiled to WebAssembly (through SQLocal) in the browser's private file system (OPFS), with files in the same OPFS tree. On Node it's a libSQL file on disk. Client and configuration has the details.
Everything your app does with data goes through that database:
Item.all,Item.find,useItemsand live queries read SQLite. They never call the network.- Assigning
post.title = '…'validates the value and queues it in the local outbox, from which it's written to SQLite. There's no request to wait for. A value that breaks a rule stays on screen with its errors and isn't written. - Drafts survive reloads, restarts and days without a connection, because they're ordinary rows on the device.
- Published items that came in through EAS sync are rows in the same tables, so one query returns drafts and published items together.
The React layer follows the same rule. SeedProvider's React Query client uses networkMode: 'offlineFirst', so hooks run their queries even when the browser reports it's offline. Their data is local anyway.
This is the reason Seed is local-first rather than "offline-capable": there's no remote copy that the local one caches. The device's database is the app's view of the world. What's on EAS and Arweave is the public record of what the user chose to publish, and the SDK copies it into the local database when it syncs.
What works offline, and what needs the network
| Works offline | Needs the network |
|---|---|
client.init, with the default gateway settings | Syncing published data in from EAS |
Loading your schema from config.schema | Publishing: Arweave uploads and EAS attestations |
| Creating, editing and deleting items, Image, File and Html values included | Revoking (unpublishing) an item |
| Queries and live queries | Downloading files that aren't on the device yet |
Ownership checks and claimUnpublishedDrafts | @seedprotocol/query and @seedprotocol/feed reads |
| Files and images already on the device | Publish cost estimates |
Init doesn't wait for the network with the default gateway settings: it looks up your models' EAS schemas in the background, and if that fails, init still succeeds and the lookup is retried later. It does wait for a proxy or Hyper sidecar you configure, and throws if it can't reach one, unless you turn that check off. See Gateway settings.
After the browser's offline event, Item.create doesn't resolve for a model whose EAS schema UID isn't stored locally yet, even after the connection returns; Creating items offline has the detail.
Files of synced items are downloaded after each sync in the browser, into OPFS. A file that hasn't been downloaded yet is fetched from an Arweave gateway when something asks for it, which fails offline. Files the user created on the device are already local.
Reading in: how EAS sync works
Published data reaches the local database through EAS sync. A sync asks the EAS indexer for the attestations made by a set of addresses, and stores the ones the database doesn't have yet.
Which addresses and which data
By default a sync covers every owned and watched address saved with client.setAddresses (or client.init({ addresses })). client.syncFromEas({ addresses }) overrides the list for one run. With no addresses, a sync does nothing.
For those addresses, a sync:
- Finds the EAS schemas for your models. Models with no EAS schema yet are skipped.
- Fetches every Seed attestation those addresses made under those schemas, including revoked ones, and inserts the Seeds it doesn't have.
- Fetches and stores their Version and property attestations, recording each value's attestation time. An attestation of an empty value, and a revoked property attestation on an item that isn't revoked, is stored as the property with no value. A property attestation whose EAS type doesn't fit the matching local property is skipped with a console warning, and so is one whose name matches a local property only at a looser
nameMatchlevel than that property allows. - Fetches the items those properties point to through relations, one level deep, whoever published them. An unfilled reference, which a publish left in a slot it never filled, counts as no related item: a relation holding one is skipped with a console warning, and a list drops it and keeps its other members.
- In the browser, schedules downloads of the synced items' files into OPFS.
- Emits
easSeedDataSyncedToDb(EAS_SEED_DATA_SYNCED_TO_DB_EVENT). In the browser,Iteminstances already in memory have read the synced rows through the change feed by then; on Node, the sync reads them again first.
Syncs aren't incremental. Each run asks the indexer for all of the addresses' Seeds and skips the ones already stored, so a sync after a long time offline costs the same as any other.
When syncs run
| Trigger | When | Notes |
|---|---|---|
| Client start | After the background EAS schema lookup finds your models' schemas | Covers the addresses passed to init. Runs whatever syncFromEasOnAddressChange is set to |
| Address change | client.setAddresses saves a non-empty address list | Only when syncFromEasOnAddressChange is true, the default. Addresses passed to the first init don't count as a change, and a repeat init leaves the stored ones alone |
| Your code | await client.syncFromEas() | Resolves when the run finishes, throws if it failed |
Requests that arrive while a sync is running are merged into one follow-up run. In the browser, the automatic triggers run only in the leader tab, while client.syncFromEas() runs in the tab that calls it.
Each run is recorded in the eas_sync_processes table, so you can show progress or a "last sync failed" message with the EAS sync hooks.
What sync doesn't do
There's no background schedule, and the SDK doesn't poll EAS. What it retries is work that failed, which usually means the device was offline: the start-up schema lookup and the syncs it starts itself are retried when the browser comes back online, or on a backing-off timer. A client.syncFromEas() call that fails isn't retried; its promise rejects, though automatic syncs merged into its run are retried. A sync that succeeded isn't repeated when the connection comes back, so if your app needs fresh data after a reconnect, sync then, from the leader tab, as Syncing across devices shows. Offline retries has the timings and how to turn retries off.
A sync interrupted by closing the tab isn't resumed. The next sync marks its eas_sync_processes row failed (Interrupted: the sync stopped before it finished (tab closed?)) and fetches everything again.
How synced values meet local edits
Every value of a property is a row, and readers show the newest row for each property, revoked or not: a synced attestation is dated by its attestation time, a local edit by when it was last made. Edits you make to a published item stay local until you publish again, and getPublishPendingDiff(item) lists them; setting a property back to its published value removes its edit. A later sync doesn't replace an unpublished edit, whenever the synced value was attested: the edit stays the value, and when the synced value differs from both the edit and the value the edit replaced, the SDK records a conflict on the property. Conflict detection covers what to do with one.
Drafts never leave the device through sync. Sync only brings in what was published, so an unpublished draft written on a laptop doesn't appear on the user's phone. Syncing across devices covers the multi-device setup.
Writing out: publishing is explicit
Local edits go through the outbox, but it only queues writes to the database on the device. Nothing in it is sent anywhere when the network returns, because there's nowhere to send it: the only outbound path is publishing, which the user starts on purpose.
Publishing, with @seedprotocol/publish, runs as a process that steps through uploading to Arweave and writing EAS attestations. It saves its progress to the publish_processes table as it goes, which is what lets a publish survive a reload:
- Publishes cut off by a reload or a closed tab resume. When a page loads,
@seedprotocol/publishrestores every publish still markedin_progressand continues it from its last saved step. This happens once per page load; a tab that's already open doesn't pick up a publish another tab abandoned. - Except during a bundler or Turbo upload. The signed data items being uploaded aren't saved with the publish, so one cut off at that step has nothing to resume with. The restore marks it
interruptedinstead of continuing, and the user has to publish again. (The upload step itself refuses to run without its data items, failing withMissingSignedDataItemsErrorfrom@seedprotocol/publish, codeSIGNED_DATA_ITEMS_MISSING, rather than attesting files that were never uploaded.) - Failed publishes stay failed. If a step before the attestations fails, for example because the network dropped during an upload, the process ends as
failedand isn't retried automatically. A failure while attesting waits for a retry instead; see What a publish does. - Stopped publishes stay stopped.
PublishManager.stopPublishmarks a publishinterrupted, and it isn't resumed.
In the browser, only one tab runs a given item's publish. See Multi-tab behavior.
What this means for your code
- Treat sync and publish as the online operations. Everything else works without a connection, so they're the only parts worth showing a connection state for.
- Trigger syncs yourself when freshness matters: on reconnect, on focus, or from a refresh button.
- Show publish state, and offer to publish again. A
failedorinterruptedpublish waits for the user. The publish hooks read the saved process rows. - Pass the user's addresses to
initat each start once you know them, and callsetAddresseswhen they change, so syncs cover the right accounts. See Addresses. - If the app has to open offline, don't configure a proxy or sidecar that must answer at start-up, or turn the check off.
Related
- How Seed Protocol works: the local and public halves of a Seed app
- Client and configuration: storage, gateway settings,
setAddressesandsyncFromEas - Events:
easSeedDataSyncedToDb - Publishing and EAS sync hooks: sync process rows in React
- Multi-tab behavior and Conflict detection
- Ownership and drafts and Permanence
- Guides: Keep writing while offline, Syncing across devices, Publishing and revoking
Schemas, Models, Items, Properties
How schemas, models and model properties define what an app can store, and how items and item properties hold the records.
Items, actors and the write path
Why Seed objects behave like live views rather than plain data, and the path every write takes from assignment to an in-memory actor, through the outbox to SQLite and back.