Skip to content

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.

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.”)

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.

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.

  • On the server, at insert / update time when the current row already exists (needsExistingLookup in sql-engine.ts triggers the fetch).
  • Not on the client. The client’s optimistic apply produces the client value; if the server merges it differently, the pull brings the reconciled row back and rebuildOptimistic runs the mutator again on top.
  • Async work. The predicate runs inside the server-side transaction. No fetch, no external calls.
  • ctx-dependent logic. resolveConflict receives only server and client; 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, use crdtCounter instead.

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:

mutators.ts
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, sets bookedBy, transaction commits.
  • Second push runs book, throws SlotAlreadyBookedError, transaction rolls back, last_mutation_id advances (poison dropped), processed[] reports mutation-error.

The second client’s onError fires; the local optimistic booking reverts on the next rebuildOptimistic.

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.

When multiple conflict mechanisms are relevant, they compose:

  1. CRDT columns merge in mergeCrdtColumns first.
  2. resolveConflict runs on the merged row.
  3. Server-side mutator logic (throwing on invariant violation) runs against the resolved row.
  4. Client-side pre-checks (in React) prevent the mutation from firing when the local view already shows the failure state.