Keep writing while offline
Let users create and edit items without a connection, detect when they're offline, queue publishes until the network returns, and recover publishes a closed tab interrupted.
Seed apps read and write a database on the device, so most of the app keeps working when the connection drops. This guide covers the parts that don't: publishing, and what to show the user while they wait for the network.
You'll need:
- A working app from Add Seed to your app, with the
blogschema from Your first schema - For the publish queue:
@seedprotocol/publishset up as in Publishing your first item
What works offline
| Works offline | Needs the network |
|---|---|
client.init with the default gateway settings | EAS sync (client.syncFromEas and the automatic syncs) |
Reading: Item.all, Item.find, hooks and live queries | Publishing and unpublishing |
| Creating, editing and deleting items, including Image, File and Html values | Downloading files that aren't on the device yet |
Ownership checks and claimUnpublishedDrafts | Remote reads through @seedprotocol/query and @seedprotocol/feed |
| Publish cost estimates |
An Image, File or Html property whose file isn't on the device reads undefined until the file is downloaded, and its valueStatus is 'missing', so you can show a placeholder offline.
Edits go through the outbox, which only queues writes to the database on the device. Nothing in it leaves the device: an edit stays local until the user publishes. Local-first and offline explains why.
If you configure a gateway proxy (gateway.proxyBaseUrl) or transport: 'hyper', init checks that it answers and throws when it doesn't. For an app that must open offline, set gateway.hyper.probeSidecar: false. See Gateway settings.
Creating items offline
Item.create, useCreateItem and saves of Image, File and Html values work offline. They need the model's EAS schema UID, which the SDK reads from the local database (stored during an earlier online start) before it asks EAS; when neither has it, the item is created without one and publishing looks it up later.
Once the browser has fired its offline event, Item.create and useCreateItem don't resolve for a model whose EAS schema UID isn't stored locally, and they stay pending after the connection comes back, because the EAS lookup they wait on never finishes. Every later create of that model waits on the same lookup until the page reloads. A page loaded without a connection, which hasn't seen the event, creates items as described above.
Detect when the user is offline
The SDK doesn't track connectivity. Use the browser's navigator.onLine and its online/offline events:
import { useSyncExternalStore } from 'react'
function subscribe(onChange: () => void) {
window.addEventListener('online', onChange)
window.addEventListener('offline', onChange)
return () => {
window.removeEventListener('online', onChange)
window.removeEventListener('offline', onChange)
}
}
export function useOnline(): boolean {
return useSyncExternalStore(subscribe, () => navigator.onLine, () => true)
}navigator.onLine === false reliably means offline. true only means the device has a network, not that EAS or Arweave are reachable, so still handle errors from sync and publish. Use the flag to change what the UI offers (a "Publish when online" button, a banner), not to block reads and edits, which don't need the network.
Queue publishes for when the network returns
Nothing queues a publish, and it doesn't wait for the network: its steps are uploads and transactions, and when one fails the publish stops, either as failed or waiting for you to retry its attestations. Nothing retries it automatically. So don't start one offline: remember the item, and start the publish when the connection comes back.
import { Item, client, whenLeaderTab } from '@seedprotocol/sdk'
import type { IItem } from '@seedprotocol/sdk'
import { ensureWalletThenPublish } from '@seedprotocol/publish'
const QUEUE_KEY = 'my-app:publish-queue'
function readQueue(): string[] {
try {
return JSON.parse(localStorage.getItem(QUEUE_KEY) ?? '[]')
} catch {
return []
}
}
function writeQueue(ids: string[]) {
try {
localStorage.setItem(QUEUE_KEY, JSON.stringify([...new Set(ids)]))
} catch {
// Storage unavailable: the item stays a draft, and the user can publish it again later.
}
}
// True when the item is now publishing, in this tab or another. Any other outcome
// (no publish wallet yet, account not ready) or an error leaves it queued.
async function startPublish(item: IItem<any>): Promise<boolean> {
try {
const { outcome } = await ensureWalletThenPublish(item as Item<any>)
return outcome === 'started' || outcome === 'already_publishing'
} catch (error) {
console.warn('Publish not started, keeping it queued', error)
return false
}
}
export async function publishOrQueue(item: IItem<any>): Promise<'started' | 'queued'> {
if (navigator.onLine && (await startPublish(item))) return 'started'
writeQueue([...readQueue(), item.seedLocalId])
return 'queued'
}
export function queuedSeedLocalIds(): string[] {
return readQueue()
}
export async function flushPublishQueue() {
if (!navigator.onLine) return
const done = new Set<string>()
for (const seedLocalId of readQueue()) {
const item = await Item.find({ seedLocalId })
// Drop items deleted since they were queued, and items whose publish started.
if (!item || (await startPublish(item))) done.add(seedLocalId)
}
// Re-read: the user may have queued more while this ran.
writeQueue(readQueue().filter((id) => !done.has(id)))
}
// Call once on start-up, and flushPublishQueue again after the wallet connects. It waits for the
// client: an Item.find before client.init rejects once readyTimeout passes.
export async function startPublishQueue() {
await new Promise<void>((resolve) => client.onReady(resolve))
await whenLeaderTab()
window.addEventListener('online', () => void flushPublishQueue())
await flushPublishQueue()
}ensureWalletThenPublish publishes with the publish wallet and its data item signer, and resolves once the publish has started, not finished. When it can't start one, its outcome says why; Other exports lists them.
How it fits together:
- The queue only holds IDs. The item's content is already in the local database. When the publish starts, it publishes whatever the item holds then, including edits made while it waited.
- Publishing needs a publish wallet. After a reload, the wallet may reconnect later than the client starts, so the first flush can find none (
no_wallet, orno_addressormanaged_not_readywithuseModularExecutor) and keep the item queued. Flush again when the wallet connects. - Only the leader tab flushes, so tabs don't race to start the same publishes. If two do, the second gets
already_publishingand nothing runs twice. 'started'means the publish process started, not that it finished. Show its progress with the publish hooks, below.localStorageis per browser profile and per origin, which matches the database. Swap in your own store if you prefer; the queue only needs to survive a reload.
To publish and wait for the result instead, use item.publish() as in Publishing and revoking; a flush would then publish the queued items one after another.
Show the state
import { useState } from 'react'
import { PublishManager, useItemPublishStatus } from '@seedprotocol/publish'
import type { IItem } from '@seedprotocol/sdk'
import { publishOrQueue, queuedSeedLocalIds } from './publishQueue'
import { useOnline } from './useOnline'
const RECOVERABLE = [
'attestationFailureRecoverable',
'attestationFailureRecoverableDirectToEas',
'retractionFailureRecoverable',
]
export function PublishButton({ item }: { item: IItem<any> }) {
const online = useOnline()
const { latestRecord, isActive, publishProcess, publishValue } = useItemPublishStatus(item.seedLocalId)
const [queued, setQueued] = useState(() => queuedSeedLocalIds().includes(item.seedLocalId))
const publish = async () => {
setQueued((await publishOrQueue(item)) === 'queued')
}
if (queued && !online) return <span>Will publish when you're back online</span>
// Attestations or revocations failed (for example, the connection dropped): retry them without
// uploading again.
if (publishValue && RECOVERABLE.includes(publishValue)) {
return (
<button onClick={() => PublishManager.retryAttestations(item.seedLocalId)} disabled={!online}>
Retry publishing
</button>
)
}
// Running in this tab. An in_progress row with no process here is running in another
// tab, or stalled (see Troubleshooting).
if (isActive && publishProcess) return <span>Publishing…</span>
return (
<>
<button onClick={publish}>{online ? 'Publish' : 'Publish when online'}</button>
{latestRecord?.status === 'failed' && (
<p role="alert">The last publish failed: {latestRecord.errorMessage}</p>
)}
</>
)
}publishProcess and publishValue describe a publish running in this tab; latestRecord is the item's newest publish_processes row, from any tab or page load. See useItemPublishStatus.
Recover interrupted publishes
In the browser, @seedprotocol/publish restores publishes left in_progress as soon as a page imports it, whether or not the device is online. A restored publish that can't reach the network fails like any other. Before the attestations it ends failed, so offer to publish again once the user is back online. During them it keeps its uploads and waits for PublishManager.retryAttestations, which PublishButton offers once online. Handle failures and resume covers every state, including a publish cut off during its bundler or Turbo upload, which is marked interrupted and has to be started again.
On Node, nothing restores until you call await PublishManager.ready() from @seedprotocol/publish.
Troubleshooting
A publish's row stays in_progress with nothing running it. The tab that was publishing closed while other tabs stayed open. The next page load resumes it, unless it was cut off at a step it can't resume from, such as a bundler or Turbo upload; then it's marked interrupted. Reload the app, or start the publish again.
After reconnecting, data from other devices doesn't appear. Coming back online only retries an automatic sync that failed; it doesn't start a fresh one after a sync that succeeded. Call client.syncFromEas() on the online event, as in Syncing across devices.
Related
- Local-first and offline: what's local, what's not, and why only publishing leaves the device
- Multi-tab behavior: the leader tab and publish locks
- Item:
Item.create - Client and configuration: gateway settings that need the network at start-up
- Guides: Syncing across devices, Publishing and revoking, Optimistic item editing
Sync a user's data to another device
Get a user's published items onto their second device with EAS sync, trigger syncs at the right moments, show sync status, and know which edit wins when two devices change the same item.
Change a schema after you've shipped
Which schema changes are safe once users have local data and published items, how to rename or replace a property, and how to roll a change out without a migration system.