Push, Pull, Rebase
plasma.start() opens a sync loop that runs three phases on a
repeating schedule. Each phase is an HTTP call to your Worker plus
some IDB work. This page walks through each one so a stack trace or
onError payload isn’t magic.
Phase 1 — Push
Section titled “Phase 1 — Push”Trigger: a mutation just landed in the outbox, or the online handler fired.
The browser gathers every outbox entry whose blob deps (if any) are
uploaded, packages them into a PushRequest, and POSTs to
/sync/push.
POST /sync/push{ "protocolVersion": 1, "schemaVersion": "todos-v1", "clientGroupID": "user-42", "clientID": "aaaa-bbbb", "mutations": [ { "id": 12, "name": "markDone", "args": { "id": "t1" }, "timestamp": 1730100000000 } ]}The Worker:
- Runs
auth()and rejects with 401 if it returns{ ok: false }. - For each mutation, dedup-checks (
clientGroupID,clientID, id) against_plasma_client_mutations. Already-processed entries are skipped. - Runs the mutator canonically against a D1 transaction. Each
db.insert(...)/db.update(...)/db.delete(...)writes to the user table. - AFTER-write triggers plasma installed on migration copy the write
into
_plasma_changeswith a monotonicrow_version. - On mutator throw, the transaction rolls back but
_plasma_client_mutations.last_mutation_idstill advances so the client’s outbox drops the poison mutation.
How the client learns what the server did
Section titled “How the client learns what the server did”The push response itself is minimal:
{ "ok": true }Success or failure of individual mutations isn’t reported inline — plasma treats push as fire-and-forget. The server:
- On mutator success: writes land in the change log, and the
next pull carries them back plus a bumped
lastMutationIDsentry for this client. - On mutator throw: the transaction rolls back, but
_plasma_client_mutations.last_mutation_idstill advances (so the poison mutation isn’t retried forever). No change log rows are produced. The client learns about the drop on the next pull, whenlastMutationIDssays “we’ve seen up to N” but no matching row appeared in the patch —dropConfirmedremoves the outbox entry,rebuildOptimisticreruns the surviving outbox, and the failed mutation’s optimistic effect vanishes.
That’s why there’s no mutation-error kind in SyncClientError —
the client only sees push HTTP failures directly (push-http /
network). Mutator throws surface as rows silently reverting on
the next rebase. Design the UX for that reversion; a pre-check in
the caller usually beats a post-hoc toast.
Phase 2 — Pull
Section titled “Phase 2 — Pull”Trigger: a poll timer (default 5s), a WebSocket poke, an
online event, or a manual client.pullOnce().
The browser sends its current pull cookie (c1: prefix + base64
JSON of per-region cursors) as a ?cookie=… query param on
/sync/pull.
GET /sync/pull?protocolVersion=1&schemaVersion=todos-v1 &clientGroupID=user-42&clientID=aaaa-bbbb &cookie=c1:eyJ...The Worker:
- Parses the cookie, computes the “since” watermark.
SELECTfrom_plasma_changeswhererow_version> watermark, filtered by each row’sauth.read(ctx, row).- Returns the change list + the new cookie +
lastMutationIDs(per-clientID watermarks so the browser knows which of its outbox entries the server has already processed).
{ "cookie": "c1:eyJhIjoiMTIzIn0=", "patch": [ { "kind": "put", "table": "todos", "key": "t1", "value": { "id": "t1", "done": 1 } } ], "lastMutationIDs": { "aaaa-bbbb": 12 }, "hasMore": false}If hasMore: true, the browser follows up with another pull
carrying the new cookie until it drains.
Phase 3 — Rebase
Section titled “Phase 3 — Rebase”After a pull, the browser holds:
- The base store — needs to update with the new patch
- The outbox — needs its confirmed entries dropped
- The user-visible store — needs to reflect (base + surviving outbox), in the right order
The rebase runs rebuildOptimistic:
applyPatchToBase(patch)— write everyput/delinto the<table>_baseobject store.dropConfirmed(clientID, lastMutationIDs[clientID])— remove outbox entries the server confirmed.- Pause the reactive hub. Suspend live-query notifications so subscribers don’t see intermediate states.
- For each user table:
- Clear the user-visible store.
- Copy every row from the base store into it.
- Replay each surviving outbox entry — same mutator, same args, same
optimistic engine as the original
mutate()call. - Resume the reactive hub. Every table touched by the rebase fires exactly one notification. Live queries recompute their window.
When any phase fails
Section titled “When any phase fails”Every phase’s error goes through the same SyncClientError union
that flows to your onError handler. The complete kind list:
| Kind | Meaning | Recovery |
|---|---|---|
push-http |
Push returned non-2xx after all retries | plasma keeps retrying; you get one event per exhaust |
pull-http |
Pull returned non-2xx after all retries | Same |
network |
fetch threw (offline, DNS, TLS) | plasma keeps retrying with backoff |
rebase-replay |
Optimistic replay of a queued mutator threw during rebuildOptimistic |
Swallowed; the next push carries the mutation and the server-side failure produces a silent revert |
schema-mismatch |
Server sent 409 with expected schemaVersion |
onSchemaMismatch({ phase }) → "reset" | "stay" |
blob-upload-failed |
R2 PUT retries exhausted | client.retryBlobUpload(mutationID) or discardMutation(mutationID) |
Server-side mutator throws (auth denied, validation) do not fire
a SyncClientError — see the previous section. Watch for the row
reverting on the next rebase.
What to read next
Section titled “What to read next”- Change log and cookies — the data structures that make each phase possible
- Sync errors (Troubleshooting) —
triage by error
kind - Testing — writing tests that simulate each phase without a real Worker