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.
A user who writes on their laptop wants to see the same posts on their phone. The only route between devices is what the user has published: each device pulls the user's attestations from EAS into its own local database. This guide sets that up and adds what the SDK leaves to your app.
You'll need:
- A working app from Add Seed to your app, with the
blogschema from Your first schema - Publishing set up with
@seedprotocol/publish, as in Publishing your first item. Without it, nothing leaves the first device.
What moves between devices
| Moves to the other device | Stays on the device it was written on |
|---|---|
| Items the user published, with the values from their latest published version | Drafts that were never published |
| Edits the user published later (new attestations) | Edits to a published item that haven't been published yet |
| Items those items point to through relations, one level deep | Files of synced items on Node (see Node) |
| Published items of watched addresses | Sync history, publish history and other local tables |
Only attestations made by the item's own account count: sync ignores Versions and property values that another account attested against the user's items, and removes any that an earlier SDK version stored.
So the workflow for a user is: write anywhere, publish, then the other device picks it up on its next sync. There's no draft sync and no peer-to-peer channel between devices. Local-first and offline explains how a sync works.
Tell each device whose data to sync
A sync fetches the attestations made by the client's owned and watched addresses. On a fresh device those lists are empty, and a sync with no addresses does nothing. So every device needs the user's address before anything arrives.
With @seedprotocol/publish
Connecting a wallet through ConnectButton, or calling setPublishWallet, saves the wallet's addresses (including the publishing account) as the owned list, keeps the watched list, and claims the device's unclaimed drafts. Saving new addresses starts a sync, so connecting the same wallet on the second device is enough to pull in what the user published. Disconnecting clears the owned list and keeps the watched list; Ownership and drafts explains the two lists.
Without it, or for your own account system
Call setAddresses yourself when you learn the user's address:
import { client } from '@seedprotocol/sdk'
export async function onSignedIn(account: string) {
const watched = await client.getWatchedAddresses()
await client.setAddresses({ owned: [account], watched })
await client.claimUnpublishedDrafts(account)
}
// On a shared device, when the user signs out.
export async function onSignedOut(account: string) {
await client.removeLocalCopiesForAddresses([account], { includeUnpublished: true })
const watched = await client.getWatchedAddresses()
await client.setAddresses({ owned: [], watched })
}setAddresses starts an EAS sync in the background and resolves once the addresses are saved, not when the sync finishes. To wait for the data, call await client.syncFromEas() afterwards. If you'd rather start every sync yourself, pass syncFromEasOnAddressChange: false to client.init.
Drafts written while no one was signed in have no publisher. claimUnpublishedDrafts gives them to this account, so they become read-only once the user signs out, and the next account to sign in on the device doesn't claim them. Drafts created after setAddresses get the account as their publisher while it's the only owned address.
removeLocalCopiesForAddresses deletes the local copies of the account's published items, and with { includeUnpublished: true } the edits to them that weren't published yet. Without the option, a copy with an unpublished edit or a pending write stays while its publisher is still an owned address, and its local id is listed in the result's keptSeedLocalIds. Drafts stay either way, and nothing changes on-chain; signing in again and syncing brings the published items back.
To wipe a shared device entirely instead, call client.resetLocalData() in the browser. It stops the client in every tab and deletes the database and stored files, drafts and saved addresses included, so the next init starts empty (Deleting local data). It leaves localStorage, where @seedprotocol/publish keeps the in-app wallet session; call clearInAppPublishSession from @seedprotocol/publish/thirdweb to sign that out too.
On every start
Once you know the user's address, pass it to client.init each time the app starts:
import { client } from '@seedprotocol/sdk'
import type { SeedConstructorOptions } from '@seedprotocol/sdk'
import schema from '../seed.schema.json'
// Without `owned`, `addresses` is left out, so the stored addresses are kept.
export function seedOptions(owned?: string[]): SeedConstructorOptions {
return {
config: { schema },
...(owned ? { addresses: { owned } } : {}),
}
}
export async function startSeed(owned?: string[]) {
await client.init(seedOptions(owned))
}Init saves these addresses, and the start-up sync covers them. Leaving addresses out keeps the stored ones, and empty lists clear them, so don't pass empty lists once you have addresses. In React that also goes for a change to SeedProvider's addresses prop, which runs setAddresses: a [] while a wallet reconnects clears them (Changing addresses). For an address you learn after the app has started, call setAddresses, since only the first init applies addresses: addresses passed to the first init don't start a sync of their own and wait for the start-up sync. Client and configuration has the details, including repeat inits.
Decide when to sync
The SDK syncs on its own at start-up, once it has found your models' EAS schemas, and after setAddresses saves new addresses, in the leader tab only. It retries those syncs when they fail, usually because the device was offline. When syncs run lists the triggers, and Offline retries has the retry timings and how to turn retries off.
Nothing else syncs: there's no schedule, a reconnect after a successful sync doesn't start one, and a client.syncFromEas() call that fails isn't retried. Add the triggers your app needs:
import { client, whenLeaderTab } from '@seedprotocol/sdk'
export async function syncNow(): Promise<boolean> {
try {
await client.syncFromEas()
return true
} catch (error) {
console.warn('EAS sync failed', error)
return false
}
}
const MIN_FOCUS_SYNC_INTERVAL_MS = 5 * 60_000
// Call once, after client.init has resolved.
export async function keepInSync() {
// One tab per device is enough: the others see the results through the shared database.
await whenLeaderTab()
// Catch up after a reconnect. The SDK only retries syncs that failed.
window.addEventListener('online', () => void syncNow())
// Catch up when the user comes back to the app, at most every few minutes.
let lastFocusSync = Date.now()
document.addEventListener('visibilitychange', () => {
if (document.visibilityState !== 'visible' || !navigator.onLine) return
if (Date.now() - lastFocusSync < MIN_FOCUS_SYNC_INTERVAL_MS) return
lastFocusSync = Date.now()
void syncNow()
})
}client.syncFromEas() resolves when the run finishes and throws if it failed. It runs in the tab that calls it, not only in the leader, which is why keepInSync waits for whenLeaderTab(): without it, every open tab syncs on every reconnect. Syncs aren't incremental: each run asks the indexer for all of the addresses' Seeds and skips the ones it already has, so don't sync on a short timer. If you want a timer anyway, Multi-tab behavior has a leader-only startPeriodicSync.
Show sync status
Every sync run, automatic or yours, writes a row to the eas_sync_processes table, and @seedprotocol/react reads it with the EAS sync hooks. Rows are newest first. SeedClientGate mounts its children before the client is ready, and until then the hooks return undefined, so SyncStatus renders nothing.
import { useState } from 'react'
import { useEasSyncProcessesState } from '@seedprotocol/react'
import { client } from '@seedprotocol/sdk'
// A run left 'in_progress' by a closed tab stays that way until the next sync starts, so ignore old ones.
const STALE_AFTER_MS = 10 * 60_000
export function SyncStatus() {
const { records } = useEasSyncProcessesState()
const [syncing, setSyncing] = useState(false)
if (records === undefined) return null
const latest = records[0]
const running =
syncing ||
(latest?.status === 'in_progress' && Date.now() - latest.startedAt < STALE_AFTER_MS)
const lastSuccess = records.find((r) => r.status === 'completed')
const syncNow = async () => {
setSyncing(true)
try {
await client.syncFromEas()
} catch {
// The failed run is recorded in eas_sync_processes, so `latest` shows it.
} finally {
setSyncing(false)
}
}
return (
<div>
{running ? (
<span>Syncing…</span>
) : latest?.status === 'failed' ? (
<span role="alert">Sync failed: {latest.errorMessage}</span>
) : lastSuccess?.completedAt ? (
<span>Last synced {new Date(lastSuccess.completedAt).toLocaleTimeString()}</span>
) : (
<span>Not synced yet</span>
)}
<button onClick={syncNow} disabled={running}>
Sync now
</button>
</div>
)
}Things the rows can't tell you:
- A run cut short stays
in_progressuntil the next sync starts. If the tab closes mid-sync, the next sync marks its rowfailedwithInterrupted: the sync stopped before it finished (tab closed?). Until then the row looks like a running sync, which is why the component only trusts a recentin_progressrow. - "Completed" doesn't mean anything arrived. A sync with no addresses, or for models that have no EAS schemas yet, finishes straight away as
completed. - A start-up sync that never ran leaves no row. If the device was offline at start-up, there's nothing new in the table until a retry runs.
The requestPayload column is JSON with a source field (models_init for the start-up sync, address_change, client_api for syncFromEas, and retry for a retried sync) if you want to show what started a run.
What wins when both devices edit
Each device keeps every value it has seen for a property and shows the newest, but an unpublished edit always stays over a synced value. Conflict detection explains the rule. In practice:
- Both devices publish an edit to the same property: the attestation made later wins on every device after their next sync. The other edit stays on-chain but isn't shown, and nothing tells the user.
- One device publishes, the other has an unpublished edit: the unpublished edit stays the value on its device, whenever the other was published, and the next publish from that device sends it. If the published value differs from both the edit and the value the edit replaced, the SDK records a conflict on the property.
Sync before editing a published item, so the user edits the value already published elsewhere, not an older one. When the user has unpublished edits, sync and then check the item's conflicts:
import { client } from '@seedprotocol/sdk'
import type { IItem, PropertyConflict } from '@seedprotocol/sdk'
// Sync, then list the properties where a synced value differs from the user's unpublished edit.
export async function syncAndListConflicts(item: IItem<any>): Promise<PropertyConflict[]> {
await client.syncFromEas()
return item.conflicts()
}
// 'local' keeps the user's edit; 'remote' takes the synced value.
export async function settleConflict(
item: IItem<any>,
conflict: PropertyConflict,
choice: 'local' | 'remote',
): Promise<void> {
const property = item.properties.find((p) => p.propertyName === conflict.propertyName)
await property?.resolveConflict(choice)
}Each conflict has the user's edit as localValue and the synced value as remoteValue, as stored strings: a List as JSON, a Relation as the related item's ID. 'remote' deletes the property's unpublished values on this device, so the synced value shows and the next publish has nothing to send for it. A conflict stays until it's settled or the user edits the property again, and syncing the same attestation again doesn't add another. An edit still waiting in the outbox when the sync runs records its conflict when it's applied, so a later item.conflicts() call lists it.
@seedprotocol/react has no conflict hook. useItemProperty re-renders when sync or another tab records or resolves a conflict, so a component can show property.conflict and call these from an event handler. ItemProperty: Conflicts has the API, and the collaborative document recipe offers the user a keep-or-take choice.
To warn before a publish overwrites someone else's, see the publishedElsewhere check in Conflict detection.
On Node
Sync works the same on Node, and the process is always the leader. Failed automatic syncs are retried on a timer only, since there's no online event, so call client.syncFromEas() on a timer of your own for anything newer. Files of synced items (images, file properties, HTML bodies) aren't downloaded. Using Seed in Node and Electron has a server that syncs.
Troubleshooting
Nothing arrives on the second device. Check, in order:
await client.getAddresses()lists the user's publishing account. With@seedprotocol/publish, that's the account the items were attested from, not necessarily the wallet's own address.- The item was published from the first device, not just saved. Drafts don't sync.
- A sync ran after the address was set. Look at the newest
eas_sync_processesrow, or callawait client.syncFromEas()and catch the error. Item.allis called withincludeEas: true. Without it,Item.allreturns local drafts only, so synced items don't show. See Item.all.
The app opened offline and never synced. The start-up lookup and sync are retried when the browser comes back online, or on a backoff timer. If that doesn't happen (for example, retries are off because NODE_ENV is test), add the online trigger above, or a "Sync now" button.
Related
- Local-first and offline: how EAS sync works and what it doesn't do
- Conflict detection: which value shows when devices disagree
- Ownership and drafts: owned and watched addresses, claiming drafts
- Multi-tab behavior: the leader tab and where syncs run
- Client and configuration:
setAddresses,syncFromEasandsyncFromEasOnAddressChange - Publishing and EAS sync hooks:
useEasSyncProcessesand friends - Guides: Keep writing while offline, Publishing and revoking, Reading published data
Publishing and revoking items
Publish edits and new versions, remove published values, publish related items together, estimate costs, confirm large uploads, recover from failed publishes, publish unattended with session keys, and unpublish.
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.