Client
The browser-side outbox, sync worker, and tracked ORM
@prisma-next-idb/sync-extension-idb wraps your IDB ORM client so every mutation atomically writes an outbox event alongside the model write, then gives you a worker that drains that outbox to your server and pulls remote changes back.
This page is the reference for that client. For the setup sequence — install, schema, config, opening the client — start at Sync.
Opening a sync client
createAutoMigratingSyncIdbClient (from @prisma-next-idb/sync-extension-idb/client) composes migrating the database — including the sync extension's own _idb_sync_outbox and _idb_sync_version_meta stores (see Migrations) via idbSyncExtension (from the /control subpath) — with opening a sync-tracked ORM client, in one call. If the database is already migrated, createSyncIdbClient({ contract, dbName }) skips the migration step and just wraps it.
For a module-level singleton with a race-safe reset() on top of that — the pattern used in Sync and the kanban example — use createManagedAutoSyncIdbClient instead, from the same /client subpath; see Client#managed-client for what "managed" buys you.
Client shape
| Member | Description |
|---|---|
db.orm | Sync-tracked ORM — same surface as a plain IdbClient.orm |
db.withoutTracking(fn) | Runs fn against the raw ORM — no outbox events, no version-meta writes |
db.on("outboxwrite", cb) | Fires once per tracked write call, after commit, with the written entries |
db.createSyncWorker(...) | Creates a SyncWorker bound to this client |
db.rawClient | The untracked IdbClient — raw ORM, transactions, marker verification |
db.verifyMarker() | Delegates to rawClient.verifyMarker() |
db.close() | Delegates to rawClient.close() |
The outbox pattern
Every tracked mutation (create, update, delete, upsert, and bulk variants) writes an OutboxEvent record in the same IndexedDB transaction as the model write. Either both commit, or neither does — there's no window where a model write succeeds but its outbox event is lost.
await db.orm.board.create({ id: "b1", name: "My Board", userId: "u1" });
// -> Board row + an OutboxEvent row committed togetherListen for writes without re-scanning the outbox yourself:
const unsubscribe = db.on("outboxwrite", (entries) => {
// entries: readonly { modelName, operation, key, payload }[]
});outboxwrite fires once per write call — a single create() fires with one entry; a batched write (createAll(), updateAll(), a cascading delete) fires once with every entry. It only fires after the underlying transaction actually commits, so a rolled-back write never fires it.
withoutTracking
Some writes should never produce an outbox event: temporary UI state, draft records, or records you're writing because a pull just applied them from the server. Use withoutTracking for those:
await db.withoutTracking((orm) => orm.localNotes.create({ id: "draft-1", body: "..." }));Version metadata and conflict avoidance
Alongside the outbox, _idb_sync_version_meta tracks two things per synced record:
localChangePending— settruethe moment a local mutation is written, cleared only once every outbox event referencing that record has synced (or given up retrying). Whiletrue, an incoming pull for that record is skipped — an unsynced local write always wins over a concurrent pull.lastAppliedChangeId— updated on every successful pull. A pull is skipped if it's not newer than what's already applied, so a duplicate or out-of-order pull can't regress a record.
You don't interact with this store directly; SyncWorker and applyPull manage it for you.
The sync worker
const worker = db.createSyncWorker({
pushHandler: async (events, signal) =>
fetch("/api/sync/push", { method: "POST", body: JSON.stringify({ events }), signal }).then((r) => r.json()),
pullHandler: async (fromChangelogId, signal) =>
fetch(`/api/sync/pull?since=${fromChangelogId ?? ""}`, { signal }).then((r) => r.json()),
});
worker.start();Nothing starts the worker automatically — call start() once you have an authenticated session and a client ready. The worker self-schedules: it runs a push-then-pull cycle every intervalMs (default 5s) while idle, and backs off exponentially (backoffBaseMs → backoffMaxMs, defaults 1s → 30s) after consecutive failures.
| Method / property | Description |
|---|---|
start() | Begins the loop. No-op if already running. |
stop() | Lets an in-flight cycle finish; no new cycle starts. |
forceSync() | Runs one cycle immediately, ignoring backoff, then resumes the idle timer. |
on(event, cb) | "statuschange", "pushcompleted", "pullcompleted" — returns an unsubscribe fn |
status | "idle" | "pushing" | "pulling" | "error" | "stopped" |
| Option | Default | Description |
|---|---|---|
batchSize | 20 | Outbox events pushed per cycle |
intervalMs | 5000 | Idle poll interval |
backoffBaseMs | 1000 | Backoff after the first consecutive failure |
backoffMaxMs | 30000 | Backoff ceiling |
requestTimeoutMs | 30000 | Aborts a hung push/pull handler call via its signal |
A typical app also reruns worker.forceSync() on the browser's online event, and calls worker.stop() when tearing down (e.g. on sign-out), unsubscribing any "outboxwrite" listeners at the same time.