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.
network
Section titled “network”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).
push-http / pull-http
Section titled “push-http / pull-http”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:
- Rolls back the mutator’s transaction.
- Advances
_plasma_client_mutations.last_mutation_idpast the poison entry (so it isn’t retried forever). - 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.
schema-mismatch
Section titled “schema-mismatch”Cause: Client and server disagree on SCHEMA_VERSION. Covered
in Schema mismatch.
rebase-replay
Section titled “rebase-replay”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.
blob-upload-failed
Section titled “blob-upload-failed”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).
blob-read-auth-cap-hit
Section titled “blob-read-auth-cap-hit”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).
Filtering by phase
Section titled “Filtering by phase”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.
What to read next
Section titled “What to read next”- Concepts / Push, Pull, Rebase — what plasma retries automatically
- Files and blobs — full retry / discard flow for blob uploads
- Schema mismatch — the one error that has its own page