Optimistic vs Canonical
client.mutate("markDone", { id: "t1" }) returns a Promise. That
Promise resolves as soon as the optimistic view is up to date —
not when the server has confirmed the write.
This page explains what “optimistic” means in plasma, what the canonical view is, and how the two are reconciled without your code seeing intermediate states.
Two stores per user table
Section titled “Two stores per user table”Every user table lives in IDB as two object stores:
- The base store (
_plasma_base_todos) — the last confirmed server state for every row. Only the pull loop writes to it. - The user-visible store (
todos) — the base store plus every un-confirmed mutation replayed on top. This is whatdb.select().from(todos)reads.
You never touch the base store. You never even see it. But knowing it exists is the key to understanding rebases.
What mutate() does
Section titled “What mutate() does”Timeline of await client.mutate("markDone", { id: "t1" }):
- Enqueue the outbox entry. A record with a fresh monotonic
mutationIDgoes into IDB store_plasma_outbox, keyed by[clientID, mutationID]. - Invoke the mutator against the optimistic engine.
db.update (todos).set({...}).where(...)runs against the user-visible store. IDB puts land immediately. - Fire the reactive hub. Live queries whose sources include
todosare notified. React re-renders on the next tick. - Return the Promise. The caller sees
mutate()resolved.
Steps 1-4 all happen in the same async tick — typically well under 10ms. The user has already seen their action reflected in the UI.
What the sync loop does next
Section titled “What the sync loop does next”Independently of mutate()’s call site:
- The push loop picks up the outbox entry, packages it into a
push envelope, and POSTs it to
/sync/push. The Worker’s sync handler runs the mutator canonically against D1. - The pull loop polls (or wakes on a WebSocket poke) and
fetches
/sync/pull. The server responds with the change log since the client’s causal cookie. applyPatchToBasewrites the incoming changes to the base store.rebuildOptimisticwipes the user-visible store, refills it from the (freshly-updated) base store, and replays every remaining outbox entry on top.
At the end of a successful sync round, the user-visible store looks identical to what the user was already seeing — but now the base store agrees.
When the server disagrees
Section titled “When the server disagrees”Suppose the server also had a change on todos.t1 (from another
device, or from a scheduled job). After the push:
- The server ran your
markDonemutator canonically, saw the concurrent write, and reconciled per the table’s rules (last-write wins by default; CRDT columns per their merge; explicitresolveConflictif declared). - The pull that follows brings back the reconciled row.
rebuildOptimisticreplays your outbox on top of the reconciled base — but by now, the server has already confirmed your mutation, so the outbox entry is dropped bydropConfirmed, not replayed.
The user-visible store transitions from your local view to the
reconciled view. React re-renders once. There is no visible flash
because plasma pauses live-query notifications during
rebuildOptimistic and fires a single batched notification when it
resumes.
When the server rejects
Section titled “When the server rejects”If the server-side mutator throws — PlasmaAuthorizationError, a
constraint violation, whatever — the outbox entry is dropped and
your onError callback fires:
createPlasmaClient({ ..., onError: (err) => { if (err.kind === "push-http" && err.status >= 400 && err.status < 500) { toast.error(`could not save; server rejected the push`) } },})The user-visible store on the next rebuildOptimistic will no
longer reflect the failed mutation, because the server advanced
last_mutation_id past it (poison mutation drops) and the client’s
outbox drops the entry on the next pull. The UI reverts.
Retries and the outbox contract
Section titled “Retries and the outbox contract”The outbox is the only record of “this mutation happened and hasn’t been confirmed.” Two invariants:
- Every mutation writes exactly once to the outbox before the
optimistic apply. If the browser crashes between step 1 and step
2, the next boot re-invokes the mutator during
rebuildOptimistic— same result. - The outbox entry is only dropped when the server confirms
(
lastMutationIDssays “we’ve seen this ID”) or when the caller explicitly callsclient.discardMutation(id).
Because the outbox is per-tab (compound-keyed on clientID), a
tab that closes with pending mutations loses them. If you need
cross-tab durability, share the clientGroupID and let one tab
push before the other is opened.
What to read next
Section titled “What to read next”- Push, pull, rebase — the sync loop in detail
- Conflict resolution — writing
resolveConflictwhen default reconcile isn’t enough - CRDT columns — automatic convergence for counter, register, and set-shaped fields