Skip to content

Sync Errors

client.onError fires for every recoverable and non-recoverable sync event. The kind field discriminates. This page is the complete triage table for the six kinds the union defines: push-http, pull-http, network, rebase-replay, schema-mismatch, blob-upload-failed.

Cause: fetch() threw. Offline, DNS failure, TLS failure, etc.

Recovery: plasma keeps retrying automatically with exponential backoff. There’s nothing for you to do beyond signalling to the user that they’re offline.

UI:

onError: (err) => {
if (err.kind === "network") setBanner("Reconnecting…")
}

Clear the banner when the next successful push/pull comes through (no explicit event; use the outbox depth as a heuristic — if it went down, you’re back online).

Cause: The push (or pull) HTTP call returned a non-2xx status that plasma treated as unrecoverable after retries. Typically 5xx after retry exhaustion, or a 4xx the caller layer surfaces.

Recovery: plasma continues to poll; a next-attempt success implicitly resolves it. Nothing to do beyond logging.

UI:

onError: (err) => {
if (err.kind === "push-http" || err.kind === "pull-http") {
console.warn(`${err.kind} ${err.status} at ${err.url}`)
}
}

Server-side mutator throws — no client kind, silent revert

Section titled “Server-side mutator throws — no client kind, silent revert”

If a mutator throws on the server (PlasmaAuthorizationError, MutatorValidationError, or a plain throw), the push HTTP call still returns { ok: true } and no SyncClientError fires.

The server:

  1. Rolls back the mutator’s transaction.
  2. Advances _plasma_client_mutations.last_mutation_id past the poison entry (so it isn’t retried forever).
  3. Does not emit change log rows for the failed mutation.

The client learns about the drop on the next pull, when lastMutationIDs reports the advanced watermark but no matching change appeared. dropConfirmed removes the outbox entry; rebuildOptimistic reruns the surviving outbox on top of a base store that doesn’t have the mutation’s writes. The optimistic view reverts.

Design UX for the reversion. If the mutator can fail for reasons your UI can predict (quota exceeded, denied, item deleted), pre-check in the component before calling mutate(). Watching for a specific mutation’s outcome from the client side means observing the row itself disappear on the next rebase.

Cause: Client and server disagree on SCHEMA_VERSION. Covered in Schema mismatch.

Cause: A queued mutation, being replayed during rebuildOptimistic, threw. Usually because the row it referenced has since been deleted by another client (the pull brought a delete; the local replay tries db.update(...).where(id=...) and fails).

Recovery: plasma swallows the throw during rebase. The next push cycle will send the mutation to the server too — the server-side run throws for the same reason, which advances last_mutation_id past the poison entry. The outbox is emptied on the following pull.

UI: Nothing to do at the rebase-replay boundary. If your logs show a stream of rebase-replay for one mutation, it means the row-not-found race has persisted for more than one pull cycle; that’s usually a hint that a scheduled delete is racing with the mutation’s push and inspection is warranted.

Cause: R2 PUT /sync/blob/:hash failed after blobUploadRetry.maxAttempts (default 5). Network-adjacent, but gated on its own retry budget.

Recovery: The upload record moves to state: "failed". The mutation whose blob deps it holds is BLOCKED — the outbox entry won’t push until the upload resolves.

Event shape: { kind: "blob-upload-failed", hash, attempts, error }. Note the event carries the blob’s hash, not a mutation ID — one hash can back several outbox entries, so recovery is hash-scoped.

UI actions:

onError: (err) => {
if (err.kind === "blob-upload-failed") {
setPendingUploads((prev) => [...prev, err])
}
}
// Later, in the UI:
<button onClick={() => client.retryBlobUpload(err.hash)}>Retry</button>

retryBlobUpload(hash) re-queues with a fresh attempt budget. There’s no per-hash discard — to abandon the mutation entirely, call client.discardMutation(mutationID) for each of the outbox entries that reference the failed hash (walk client state or use the Devtools panel to find the IDs).

Cause: A GET /sync/blob/:hash request had more references than SyncHandlerOptions.readAuthMaxRefs (default 128), so the server didn’t check every ref before deciding whether to serve.

Recovery: The blob request completes normally — the cap is a performance guard, not a correctness one. If any of the checked refs passed auth.read, the blob is served; if none did, it’s 404.

UI: Server-side event, not exposed via client.onError. Emit it from your server-side telemetry hook. If you see it, the affected user may be hitting authorisation gaps for shared blobs (a fix is usually to trim _plasma_blob_refs of the least-recent references to lower the fan-out).

Only the network and schema-mismatch kinds carry a phase: "push" | "pull" field — for those two, you can distinguish push-side (user just tried to save) from pull-side (background poll) failures:

onError: (err) => {
if ("phase" in err) {
console.error(`[${err.phase}] ${err.kind}`, err)
} else {
console.error(`[?] ${err.kind}`, err)
}
}

The other kinds are implicitly phase-scoped: push-http is push, pull-http is pull, rebase-replay is rebase, blob-upload-failed is the upload worker.