Conflict Resolution
Most concurrent-edit scenarios in plasma need no special handling:
the sync loop pushes both mutations, the server serialises them
into _plasma_changes, and the client rebases on top. The
“conflict” is imperceptible.
This page is about the cases that aren’t imperceptible.
The default: last-write-wins by row_version
Section titled “The default: last-write-wins by row_version”When two clients update the same row concurrently, plasma serialises their pushes on the server. Each mutator runs against the current row and produces a new row. The server-side transaction commits one write at a time, so the second write sees the first’s row and proceeds from there.
For most fields, this is fine: whichever mutation reached the server
second is the one whose title / body / updatedAt you see.
The subtle case: the mutator you wrote might make an assumption that no longer holds after the earlier update. Example:
markUrgentIfTitleContainsUrgent: async ({ db, args }) => { const rows = await db.select().from(todos).where(eq(todos.id, args.id)) if (rows[0]?.title.toLowerCase().includes("urgent")) { await db.update(todos).set({ priority: 1 }).where(eq(todos.id, args.id)) }}Client A edits the title from “Buy milk” to “Buy milk URGENT”. This mutator (on the client) sees the new title, sets priority to 1.
Meanwhile Client B removed “URGENT” from the title. Server order: B’s title-edit lands first, then A’s mutator runs — sees “Buy milk” (no “urgent”), doesn’t set priority. Different outcome on the server than the client’s optimistic apply.
The client’s optimistic view now disagrees with the server. On the
next pull, rebuildOptimistic re-runs A’s mutator against the base
store (which has B’s title edit) — and A’s mutator now correctly
sees no “urgent” and doesn’t set priority. The optimistic view
reverts.
This is the design. The mutator ran on both sides with the same logic; the final state converges. But your UI briefly showed priority = 1 before reverting.
When to escalate
Section titled “When to escalate”The default suffices when:
- Concurrent edits touch different columns of the same row.
- Concurrent edits set the same column to the same value.
- The mutator’s logic re-runs cleanly against any state.
Escalate to resolveConflict or a CRDT column when:
- Two concurrent edits both meaningfully modify the same column and neither should lose. (“A adds ‘meeting @ 3pm’ to a note body; B adds ‘call John’ to the same body.”)
- The intended semantics are additive. (“Both clicks should count; the counter should be 2, not 1.”)
- The intended semantics are set-shaped. (“A likes; B likes; the reactions should include both.”)
Escalation 1 — CRDT columns
Section titled “Escalation 1 — CRDT columns”For counters, registers, and sets, CRDT columns are the right answer:
- Counter that only grows:
crdtCounter - Counter that swings:
crdtPnCounter - Register with last-writer-wins semantics but a predictable tiebreak:
crdtLwwRegister - Set with concurrent add/remove semantics:
crdtOrSet
The server auto-merges these; you don’t declare a
resolveConflict.
Escalation 2 — resolveConflict
Section titled “Escalation 2 — resolveConflict”For custom merges — usually because the field isn’t a counter/set
but concurrent semantics still need care — declare it on
TableOptions.resolveConflict:
const todos = table("todos", { id: id(), title: text(), done: int().default(0), updatedAt: int(),}, { resolveConflict: ({ server, client }) => ({ ...server, ...client, // Union the "done" flag: once anyone marks it done, it stays done. done: Math.max(server.done ?? 0, client.done ?? 0), // Keep the most recent updatedAt. updatedAt: Math.max(server.updatedAt ?? 0, client.updatedAt ?? 0), }),})resolveConflict receives the row that would result from just
running the mutator (client) and the current server row
(server). Return the reconciled row that should actually be
committed.
When it runs
Section titled “When it runs”- On the server, at insert / update time when the current row already
exists (
needsExistingLookupinsql-engine.tstriggers the fetch). - Not on the client. The client’s optimistic apply produces the
clientvalue; if the server merges it differently, the pull brings the reconciled row back andrebuildOptimisticruns the mutator again on top.
What NOT to put in resolveConflict
Section titled “What NOT to put in resolveConflict”- Async work. The predicate runs inside the server-side
transaction. No
fetch, no external calls. ctx-dependent logic.resolveConflictreceives onlyserverandclient; it doesn’t have access to which caller ctx produced either row. If you need ctx, do the merge inside the mutator instead.- CRDT-shaped logic. If you’re implementing counter
semantics in
resolveConflict, usecrdtCounterinstead.
Escalation 3 — application-level pre-checks
Section titled “Escalation 3 — application-level pre-checks”Some conflicts aren’t solvable at the merge layer — the semantics are “the second click should error”. A booking system:
book: async ({ db, args }) => { const rows = await db.select().from(slots).where(eq(slots.id, args.slotId)) if (rows[0]?.bookedBy) { throw new SlotAlreadyBookedError(rows[0].bookedBy) } await db.update(slots).set({ bookedBy: args.userId }).where(eq(slots.id, args.slotId))}Two clients call book concurrently. On the client, both see the
slot as free and both call mutate — both get an optimistic
booking. On the server, the pushes serialise:
- First push runs
book, setsbookedBy, transaction commits. - Second push runs
book, throwsSlotAlreadyBookedError, transaction rolls back,last_mutation_idadvances (poison dropped),processed[]reportsmutation-error.
The second client’s onError fires; the local optimistic booking
reverts on the next rebuildOptimistic.
Client-side pre-check when the UX matters
Section titled “Client-side pre-check when the UX matters”If you can’t tolerate the visible flash, check the current state in
your React component before calling mutate:
function BookButton({ slot, currentUserId }) { const rows = useLiveQuery( () => plasma.db.select().from(slots).where(eq(slots.id, slot.id)), [slot.id], ) const isBooked = !!rows[0]?.bookedBy
return ( <button disabled={isBooked} onClick={() => book.mutate({ slotId: slot.id, userId: currentUserId })} > {isBooked ? "Booked" : "Book"} </button> )}The button disables the instant the local optimistic view sees the row as booked. Race conditions between the click and the local optimistic apply are effectively impossible.
The full priority order
Section titled “The full priority order”When multiple conflict mechanisms are relevant, they compose:
- CRDT columns merge in
mergeCrdtColumnsfirst. resolveConflictruns on the merged row.- Server-side mutator logic (throwing on invariant violation) runs against the resolved row.
- Client-side pre-checks (in React) prevent the mutation from firing when the local view already shows the failure state.
What to read next
Section titled “What to read next”- CRDT columns — automatic merges for counters, registers, and sets
- Mutators — the mutator boundary at which
throws propagate to
onError - Concepts / Optimistic vs Canonical — the reversion mechanism explained