Skip to content

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.

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 what db.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.

Timeline of await client.mutate("markDone", { id: "t1" }):

  1. Enqueue the outbox entry. A record with a fresh monotonic mutationID goes into IDB store _plasma_outbox, keyed by [clientID, mutationID].
  2. Invoke the mutator against the optimistic engine. db.update (todos).set({...}).where(...) runs against the user-visible store. IDB puts land immediately.
  3. Fire the reactive hub. Live queries whose sources include todos are notified. React re-renders on the next tick.
  4. 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.

Independently of mutate()’s call site:

  1. 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.
  2. 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.
  3. applyPatchToBase writes the incoming changes to the base store.
  4. rebuildOptimistic wipes 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.

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 markDone mutator canonically, saw the concurrent write, and reconciled per the table’s rules (last-write wins by default; CRDT columns per their merge; explicit resolveConflict if declared).
  • The pull that follows brings back the reconciled row.
  • rebuildOptimistic replays your outbox on top of the reconciled base — but by now, the server has already confirmed your mutation, so the outbox entry is dropped by dropConfirmed, 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.

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.

The outbox is the only record of “this mutation happened and hasn’t been confirmed.” Two invariants:

  1. 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.
  2. The outbox entry is only dropped when the server confirms (lastMutationIDs says “we’ve seen this ID”) or when the caller explicitly calls client.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.