Sync client
Reference for the browser side of sync, the outbox, and the sync worker
Reference for @prisma-idb/sync-extension-idb. To set it up, follow the Sync tutorial.
Opening a sync client
All three functions come from @prisma-idb/sync-extension-idb/client.
| Function | Use it when |
|---|---|
createManagedAutoSyncIdbClient(options) | Most apps. A shared client with get(), close() and reset(), like createManagedAutoIdbClient. |
createAutoMigratingSyncIdbClient(options) | You manage the client's lifetime yourself. Migrates, then returns a sync client. |
createSyncIdbClient({ contract, dbName }) | The database is already migrated. |
The options are the same as createAutoMigratingIdbClient's, plus trackedModels: an array of model names to record for sync, or "*" for every model, the default. Writes to other models stay local. Tracked models must use keys that are unique across devices, such as uuid() or cuid(). Sync rejects tracked stores with autoincrement() keys. Include idbSyncExtension from @prisma-idb/sync-extension-idb/control in extensions, so the database gets the sync stores, _idb_sync_outbox and _idb_sync_version_meta.
Client members
| Member | Description |
|---|---|
db.orm | The same API as a plain client. Writes to tracked models are also recorded for sync. |
db.withoutTracking(fn) | Runs fn(orm) with writes that aren't recorded, so they never sync. |
db.on("outboxwrite", cb) | Calls cb after a tracked write commits, with the recorded changes. Returns an unsubscribe function. |
db.createSyncWorker(opts) | Creates a sync worker for this client. |
db.rawClient | The underlying plain client. Writes through it aren't recorded. |
db.verifyMarker(), db.close() | Same as on a plain client. |
The outbox
Every tracked write through db.orm also writes an event to the outbox store, in the same transaction. The two commit together or not at all, so no change is ever lost or sent without being saved.
"outboxwrite" fires after commit, once per tracked IndexedDB write call. A single call can report several entries, such as the rows from a bulk scan-write. Nested writes, foreign-key-aware creates and cascading deletes can use several driver calls, so one ORM method can produce several notifications. A rolled-back transaction produces none.
const unsubscribe = db.on("outboxwrite", (entries) => {
// entries: readonly { modelName, operation, key, payload }[]
});Use withoutTracking for writes the server shouldn't receive, such as local-only drafts, or a copy of a row that came from the server:
await db.withoutTracking((orm) => orm.user.upsert({ where: { id }, create: user, update: user }));Conflicts
For each synced record, _idb_sync_version_meta records two things:
- Whether a local change is waiting to be pushed. While one is, a pull stops at that record, so a local change is never overwritten before it reaches the server.
- The last changelog id applied to it. A row that is not newer counts as already applied, so a repeated or out-of-order pull can't undo a newer change.
The worker maintains this store; you don't write to it.
The pull cursor
A pull applies its rows in order. A later row can depend on an earlier one, so the pull never skips ahead of a row it could not apply: a write failure or a pending local change stops the pull, and no later row lands before it. The one exception is an invalid row, which the pull consumes without writing it.
Each row ends in one of four ways:
| Outcome | Meaning | Effect on the pull |
|---|---|---|
applied | The row was written. | Continues. The cursor moves past it. |
already-applied | The record already has a newer or equal change. This happens when a page is replayed after a crash. | Continues. The cursor moves past it. |
invalid | The record or key fails the client contract. No retry can fix it. It is counted in validationFailed. Local data stays. | Continues. The cursor moves past it. |
| halt | The write failed (for example a full disk) or the record has a pending local change. | Stops. Later rows are not applied. The cursor stays before this row. |
The pullcompleted event reports halted: true when a pull stopped early. The next pull restarts at the halted row. A row for a model that is not in the client contract is skipped and does not halt the pull.
Rejected pushes
When the server rejects an event for good (retryable: false), the worker replaces the local row with the server's row from the push result's record. A rejected create is removed, a rejected update is replaced by the server's row, and a rejected delete is restored. The change adds no outbox event.
The rejected event stays in the outbox with retryable: false and its full payload, so the user's write is not lost. Do not delete it.
If the result has no usable record (an older server, or a record that fails the client contract), the local write stays until a pull overwrites it. The pushcompleted event counts these in unreconciled.
Avoid user-chosen natural-key unique constraints on models that clients create offline. Two devices can create the same value, and the server keeps only the first one. Use a surrogate id as the primary key. See ADR 022.
Sync worker
const worker = db.createSyncWorker({ pushHandler, pullHandler });
worker.start();The worker doesn't start on its own. Each cycle first pushes: it sends batches of outbox events until none is left to send. It then pulls, but only if no retryable event remains in the outbox. This keeps a pull from landing on an edit the server has not seen. A cycle repeats every intervalMs while idle, and backs off after failures.
The worker automatically keeps the newest 100 successfully acknowledged outbox events per database, ordered by createdAt descending and event id descending for ties. It prunes before each cycle's first push, including idle cycles, and after each push-result batch in the same transaction as the results. Unsent and failed events keep their full payload and error, including permanent rejections. Version metadata is preserved. This cleanup applies only to the worker; direct calls to low-level outbox helpers such as markSynced do not prune history.
A failed push, including a request that throws or times out, stays retryable however many times it fails, unless the server says it can never succeed. Events are pushed in order, so an event that is waiting out its backoff holds back the events behind it. After 10 failed tries, pushcompleted carries stalled: { eventId, tries, lastError } for the oldest event. Show it to the user: the outbox is stuck behind that event, and pulls pause until it clears.
| Option | Default | Description |
|---|---|---|
pushHandler | Required | (events, signal, context) => Promise<PushResult[]>. Sends events to your push endpoint. |
pullHandler | Required | (fromChangelogId, signal, context) => Promise<LogWithRecord[]>. Fetches changes after fromChangelogId, which is null on the first pull. |
getCursor | None | () => string | null | Promise<string | null>. Loads the saved pull cursor once, before the first pull. Without it the cursor is in memory only. |
setCursor | None | (changelogId) => void | Promise<void>. Saves the cursor after a pull advances it. A throw fails the cycle and the save is retried. |
batchSize | 20 | Outbox events pushed per request. |
intervalMs | 5000 | Time between cycles while idle. |
backoffBaseMs | 1000 | Wait after the first failure. Doubles with each failure in a row. Applies to failed cycles and to each outbox event. |
backoffMaxMs | 30000 | The longest wait, for both. |
requestTimeoutMs | 30000 | Aborts a handler call through its signal after this long. |
Each handler receives context.storageHash, the emitted client contract's storage.storageHash. Send it with the request, for example in a header, and forward it to the server's pull and applyPush. If the server answers 409, throw ContractMismatchError (exported from @prisma-idb/sync-extension-idb/client) from the handler. The worker then keeps the pull cursor, the queued edits and their payloads, emits contractmismatch with { during: "push" | "pull" }, and retries with backoff. Use the event to ask the user to reload. See Contract check.
| Method or property | Description |
|---|---|
start() | Starts the loop. Does nothing if it's running. |
stop() | Lets the current cycle finish, then stops. |
forceSync() | Runs a cycle now, ignoring any backoff. |
status | "idle", "pushing", "pulling", "error" or "stopped". |
on(event, cb) | Subscribes to "statuschange", "pushcompleted", "pullcompleted" or "contractmismatch". Returns an unsubscribe function. |
Most apps also call forceSync() on the browser's online event, and stop() on sign-out.
The push and pull response shapes are exported as Zod schemas from @prisma-idb/sync-extension-idb/schemas: pushResultSchema and logWithRecordSchema.