Skip to content

Docs Sync

Offline writes

Queue writes on a device with no network, show what sync is doing while they wait, and handle the ones the server refuses. A write through kizunasync.from(...) commits to local SQLite, updates the UI, and joins the outbox for the next push, so it never waits for the network. A local constraint or an unsupported operation fails on the spot.

Sarah's phone goes offline and three writes land in its list and queue in the outbox; when the network returns, one push carries all three, the first two come back applied, and the third is rejected with RLS_DENIED and leaves the list.

Write locally and Show sync state in your UI build the offline write path and the indicator your users watch. Handle a rejection and Subscribe to engine events cover the writes the server sends back. Model an invariant without a batch builder, Verify it worked, and Common errors cover the limit of the queue, the manual check, and the errors you are most likely to hit.

Before you begin#

  • The app client is already running in your app, exported as kizunasync from src/kizunasync.ts. Build one with the Quick start if you are starting from scratch.
  • The hook packages are kizunasync/react (React, Expo, and React Native) and kizunasync/vue. Mount KizunaSyncProvider, provideKizunaSync, or createKizunaSyncPlugin above the hooks. See React, Expo / React Native, and Vue.
  • Swift and Kotlin apps build the same indicator from KizunaSyncClient. on delivers engine events, outboxDepth() returns the pending count, and rejections() with dismissRejection covers the journal. Pair on with Combine or Flow to get a live status view. The host KizunaSyncScheduler, built with KizunaSyncScheduler(client:, refreshSession:), runs one sync when it starts and another after every local write to its client. It also polls every 15 seconds by default, refreshes the session JWT before each run, and syncs again on a network path change and on a return to the foreground. Read the loop state from the scheduler's health() and onHealth: phase, failure streak, next attempt, last success, last error, and needsReset. Swift and Kotlin builds the app's syncScheduler beside the app client.
  • Svelte, Solid, Angular, and every other JavaScript UI drive the app client directly through the Vanilla / other tab. There is no @kizunasync/svelte package.

1. Write locally#

Call kizunasync.from(table) the way you would call a connected database.

// src/todo-actions.ts
import { kizunasync } from './kizunasync'

// Insert, update, and delete each commit locally and queue in one transaction.
export async function addTodo(title: string): Promise<void> {
  await kizunasync.from('todos').insert({ title })
}

export async function completeTodo(id: string): Promise<void> {
  await kizunasync.from('todos').update({ done: true }).eq('id', id)
}

export async function deleteTodo(id: string): Promise<void> {
  await kizunasync.from('todos').delete().eq('id', id)
}

The insert leaves user_id out because todos declares a byOwner('user_id') bucket, and the engine writes the signed-in user's id into that column. It leaves id out as well, and the client mints a UUID for the row.

The row and its outbox entry are written in one local transaction, so a queued write and the row it produced always land together. The engine pushes entries in the order you made them. None of these functions calls sync(): every local write wakes the app client's sync loop, which pushes the outbox as soon as the network allows.

A completed pull commits the incoming checkpoint and then replays whatever is pending on top of it, so your unsent edits stay on screen and the rows other people changed stay visible underneath them.

Every mutation carries its own id, and the server keeps one verdict per id, so a push that is sent twice returns the recorded verdict instead of applying the write a second time.

You should now see the new row in the UI before any request leaves the device, and getOutboxDepth() should report one entry more than before the call.

2. Show sync state in your UI#

useSyncStatus returns the fields a sync indicator needs in React, Vue, and Expo. Call it with no argument: its isOnline follows the app client's connectivity, the online signal the sync loop itself reads. Swift and Kotlin read the loop's state from syncScheduler, the scheduler Swift and Kotlin builds beside the app client: onHealth delivers every change, outboxDepth() returns the pending count, and Sync now calls syncScheduler.wake(), which goes through the scheduler's session refresh and network gate.

// src/sync-bar.tsx
import { useSyncStatus } from 'kizunasync/react'

export function SyncBar() {
  const { outboxDepth, isSyncing, isOnline, isStalled, nextRetryAt, lastError, syncNow } = useSyncStatus()

  return (
    <div>
      <span>{isOnline ? 'Online' : 'Offline'}</span>
      {outboxDepth > 0 && <span>{outboxDepth} pending</span>}
      {isSyncing && <span>Syncing…</span>}
      {isStalled && <span>Reconnecting…</span>}
      {!isStalled && nextRetryAt !== null && <span>Retry at {new Date(nextRetryAt).toLocaleTimeString()}</span>}
      {lastError && <span>Error: {lastError.message}</span>}
      <button onClick={syncNow}>Sync now</button>
    </div>
  )
}

useSyncStatus returns eleven fields:

FieldTypeWhat it is
outboxDepthnumberMutations pending in the outbox. Rejected and dead-lettered writes move to the rejection journal instead
isSyncingbooleanA syncNow() call from this hook is in flight
isOnlinebooleanThe app client's connectivity signal, or the one you pass as the connectivity option
healthISyncHealthThe automatic loop's phase, failure streak, next attempt, and last error
isStalledbooleanTrue when health.phase is stalled
nextRetryAtnumber | nullEpoch milliseconds of the next armed automatic attempt, or null when none is armed
lastErrorError | nullThe most recent rejection, batch abort, dead letter, or thrown sync
checkpointTCheckpointStateThe persisted cursor, schema version, and soft-block flag
needsResetbooleanTrue when checkpoint.softBlocked is set. A reset banner belongs on this, not on lastError
softBlockReason'reset_required' | 'identity_changed' | nullWhy sync is blocked: the server's schema gate refused this client, or another user signed in on this device. null while sync is not blocked
syncNow() => Promise<void>Push, upload once the outbox drains, then pull

Behind the hook the app client runs its own sync loop. The first attempt runs when the app client is first used, and the loop runs again after every local write, when the network comes back, when the app returns to the foreground, and when a Realtime wake-up arrives. Between those it polls every 15 seconds by default, at a random point between half the interval and the full interval so that many clients do not arrive at once, and it stretches that gap to at most 30 seconds after consecutive failures. A manual syncNow() or an automatic tick that finds the app client's connectivity reporting offline makes no network call at all: it resolves at once, and no attempt joins the failure streak or the health snapshot.

health.phase reports what that loop is doing right now: idle, syncing, backoff, stalled, or offline. isStalled turns true once an attempt has gone two poll ticks without settling. Two ticks rather than one keep a merely slow sync from being reported as wedged. isSyncing covers your own syncNow() call alone, so a background poll never flickers the button. Sync health documents the snapshot field by field.

Turn the network off and you should now see the indicator switch to Offline with a rising pending count. Turn it back on and the count should reach zero without a reload.

3. Handle a rejection#

The device accepts a queued write locally, and the server judges it later. Row Level Security, a database constraint, a precondition, or a peer's delete can all refuse it once the push runs, so the outcome you show a user comes back seconds or hours after the edit. Supabase documents the policy side in Row Level Security.

For a recognized verdict, the engine reconciles the local row, records the outcome in the rejection journal, and emits a typed event. The original write promise settled long ago, so nothing throws at the call site. If the UI reads neither the event nor the journal, the row reverts under the user with no explanation.

Malformed envelopes and unknown protocol values throw typed errors. Those reject the sync() promise, so syncNow() reports one through lastError, and one thrown on an automatic attempt lands in health.lastError.

subscribeVerdictToasts from kizunasync maps every rejection to a message and hands it to your toast presenter in one call. Pass your presenter as notify and call showVerdictToasts once when your app starts:

// src/verdict-toasts.ts
import { subscribeVerdictToasts } from 'kizunasync'
import { kizunasync } from './kizunasync'

// notify is your toast presenter; the returned function unsubscribes.
export function showVerdictToasts(notify: (message: string) => void): () => void {
  return subscribeVerdictToasts(kizunasync, ({ title, message }) => notify(`${title}: ${message}`))
}

Each message also carries a level, 'error' or 'warning', for a presenter that styles the two differently.

Two event shapes cover individual and batched writes:

  • MUTATION_REJECTED: a single write was refused. A mutation is one unit, so the server applied none of it. The engine reverts the local row, clears the outbox entry, and emits { type, mutationId, reason }. One rejection never blocks the rest of the queue.
  • BATCH_ABORTED: an atomic batch (writes sharing a batchId) was refused server-side. The server reverts the whole batch and returns one outcome. The engine reverts every member to its pre-image and emits { type, offenderMutationId, reason }.

The reason field is a closed string union. Surface the literal in your UI rather than matching on the text:

reasonWhat happened
RLS_DENIEDRow Level Security refused the write. The local row reverts to the server row, and it is deleted when the server row is invisible to this user.
COLUMN_DENIEDThe write named a column your role may not update. The local row reverts to the server row, narrowed to the columns you may read.
PRECONDITIONThe optimistic check failed against the current server state. The local row reverts to the current server row.
CONSTRAINTA database integrity constraint, a value your column type refused, or an app trigger's bare RAISE EXCEPTION failed when the server applied the write. Unique, not-null, foreign key, check, and exclusion violations all arrive under this reason.
DELETE_WINSA peer deleted the row and left a tombstone of a bucket you already synced a live copy from, so editing it is refused. The local row is deleted.
SUPERSEDEDIn hlc conflict mode, every masked column lost the per-column HLC compare. The whole mutation reverts to the winning server row.

Caution: a client that has drifted from the server schema pack loses sync progress rather than a row. A reason value the engine does not recognize throws TEngineError(UNKNOWN_VERDICT_REASON) instead of reverting on a guess, so the write stays queued until a matching client ships. Keep the two in lockstep.

Transport failures follow a different path. Network loss, a 5xx, an expired JWT, and a Supabase session lookup that does not settle within 10 seconds (AUTH_SESSION_TIMEOUT) are all retryable, so the write stays queued and the loop tries again for as long as it takes. Supabase covers the token side in Auth sessions.

Only a failure the remote marks permanent counts against a budget, and a lone queued write or an atomic batch owns that budget outright: after five of those against it, the engine reverts the row to its pre-image, writes it to the dead-letter journal, and emits DEAD_LETTER. A permanent failure of a wider, non-atomic slice is never charged to the slice, because the response names no single mutation as the cause; the engine narrows the slice instead (halved on KZP02, cut to the head write on any other permanent code) and resends at once, so retries converge on the one write that then owns its own budget. A push failure never blocks the pull that follows it unless the failure itself is retryable, so a refused write holds back neither your own remote rows nor anyone else's.

You should now see a toast naming the reason when a write is refused, and the affected row back at the value the server holds.

4. Subscribe to engine events#

Use kizunasync.on when a toast is not enough, for example to log a rejection to an error tracker or to highlight the row that changed.

// src/sync-events.ts
import { kizunasync } from './kizunasync'

// notify shows or logs a message (a toast, an error tracker); call this once when your app starts, and the returned function unsubscribes.
export function watchSyncEvents(notify: (message: string) => void): () => void {
  return kizunasync.on((event) => {
    switch (event.type) {
      case 'LOCAL_CHANGED':
        // Local data changed after an optimistic write or a pull commit.
        // Reactive bindings (useQuery) re-read on their own; nothing to do here.
        break
      case 'MUTATION_REJECTED':
        // event.mutationId, event.reason
        // The local row is already back at the server's value.
        notify(`Write rejected: ${event.reason}`)
        break
      case 'BATCH_ABORTED':
        // event.offenderMutationId, event.reason
        // An atomic batch was refused; every member reverted.
        notify(`Batch aborted: ${event.reason}`)
        break
      case 'DEAD_LETTER':
        // event.mutationId, event.reason
        // Five permanent transport failures: the mutation left the outbox and was journaled.
        notify(`Could not sync mutation ${event.mutationId}`)
        break
      case 'COLUMN_OVERWRITTEN':
        // Tables with the conflict journal on: a column of yours lost to a peer's write.
        // event.table, event.pk, event.column, event.loserValue
        break
      case 'QUEUE_DEPTH':
        // event.depth, the same number outboxDepth reports.
        break
      case 'RESET_REQUIRED':
        // event.reason: 'reset_required' when the server's schema_version is above the client minimum
        // (prompt the user to update the app), 'identity_changed' when another user signed in on this device.
        // Sync stays soft-blocked until reset().
        break
      case 'CHECKPOINT_EXPIRED':
        // The stored cursor is too old to resume from, so the client re-hydrates from scratch.
        break
    }
  })
}

MUTATION_REJECTED, BATCH_ABORTED, DEAD_LETTER, COLUMN_OVERWRITTEN, and QUEUE_DEPTH report what became of a queued write. CHECKPOINT_EXPIRED is a different kind of news: the cursor the client saved is older than the oldest tombstone the server keeps, so the local database is filled again from the start.

useSyncStatus subscribes internally and surfaces MUTATION_REJECTED, BATCH_ABORTED, and DEAD_LETTER through lastError, so reach for kizunasync.on when that one field is not enough. COLUMN_OVERWRITTEN has its own durable read instead: kizunasync.overwrites(), or useOverwrites in React and Vue, so a component that missed the event can still show what a peer replaced.

You should now see one handler call per event while you take the device offline and back.

5. Model an invariant without a batch builder#

The engine and the wire protocol understand batchId and BATCH_ABORTED, but the application builder does not expose a batchId option. insert(values) takes one argument, and update(values, options?) and delete(options?) accept precondition alone. Do not copy examples that pass { batchId } to these methods.

Inside the engine a batch is real. When the head of the outbox carries a batch_id, the push sends that whole consecutive run as one atomic request rather than truncating it, and an aborted reply reverts every member the request carried, writes one journal row, and emits one BATCH_ABORTED. Nothing in the public JavaScript surface sets a batchId, though, so an application cannot reach that path. Express an all-or-nothing rule as one server-side operation instead.

Your code should now pass no batchId to insert, update, or delete.

6. Verify it worked#

Open the todo example app, check Offline in the network tab of DevTools, add a todo, then re-enable the network. You should see:

  1. The todo in the list at once, before any request.
  2. outboxDepth at 1 while the network is off.
  3. outboxDepth back to 0 shortly after reconnecting.
  4. No duplicated row and no flicker as the pending row is confirmed.

Test offline behavior shows the headless engine and example integration tests for retries, acknowledgments, and rejections. None of them kills a running browser or a native process, so a pass is evidence for the engine path it runs and not for crash safety.

Common errors#

  • LOCAL_UNSUPPORTED on insert, update, or delete means the table's sync is 'pull-only' in Define config. The builder refuses the call before the write reaches the outbox.
  • BUCKET_UNSET means a sync ran before setBucket filled a byColumn table, or the call carried a key no table declares as a bucket column. A byOwner table needs no call, because the engine fills it once a user signs in on the device. Sync rules & buckets covers both helpers.
  • For rejected writes with no feedback, render lastError from useSyncStatus, or subscribe with kizunasync.on and handle MUTATION_REJECTED, BATCH_ABORTED, and DEAD_LETTER. A local write is rejected and compensated works the same path from the symptom.
  • Rows flickering back after an offline edit means two devices edited the same column. Column-level last-writer-wins by server arrival order is the default. For text fields where concurrent edits matter, use an append-style model or the hlc conflict mode. See Conflict resolution.

Next steps#

Found a problem? Edit this page on GitHub