Data Synchronisation · Lesson 01

The Life of a Mutation

You already have the design: a client-side log of commands, applied optimistically, with the server's reply as the only state that counts. This lesson gives it the field's names, walks one command from keypress to confirmation, and shows where the shipping engines disagree with your instinct.


Your words, and theirs

The model you sketched is, almost exactly, the one Replicache documented in 2020 and Zero inherited. Their vocabulary is the most precisely written down, so it is the one these lessons use. The mapping, once, so you never have to translate again:

You saidReplicache / ZeroTanStack DBFigma
command (the function)mutatorhandler
command (one entry in the log)mutationmutation, inside a transactionedit
dirtypendingoptimistic stateunacknowledged
committingpushed, not yet confirmedpersistingunacknowledged
committed / "state from the backend"server-authoritative state; the Client View at a cookiesynced data
state + commands appliedrebase"inner loop superseded by the outer loop"

Two things worth noticing in the table. Nobody has a word for your "committing", because in every engine it is not a separate state — a mutation is pending until it is confirmed, whether or not it has left the device. And the word for "state + commands applied" is a verb, because it names a process that runs repeatedly, not a value that is stored once.

Five definitions

From the Replicache documentation, with the type each one implies:

Mutator“a named JavaScript function that operates on Replicache.” It runs on the client, against local state, and the same mutator by name runs on the server. Business logic lives here, once.
Mutation“a record of a mutator being called with specific arguments.” Name plus arguments plus an id. Not the effect — the call. The effect is recomputed every time the mutation is replayed.
Pending“Until the mutations above are pushed by Replicache to the server during sync they are pending (optimistic).” A mutation stays pending until the server has said it processed it — not until it has been sent.
Client View“an ordered map of key/value pairs … persisted in the underlying cache.” The last authoritative state the client received, tagged with a cookie: “a value opaque to the client identifying the canonical server state.”
Mutation id“a sequential integer uniquely identifying the mutation in this client.” The server keeps, per client, the last mutation id it has processed. That one integer does most of the protocol's work, as you will see.
type Mutation = { id: number; name: keyof typeof mutators; args: unknown }

type Client = {
  authoritative: State      // the Client View — never mutated by the UI
  cookie: string            // which server version `authoritative` is
  pending: Mutation[]       // your "dirty" log, in id order
}

// Frontend state is not stored. It is derived, every time:
const view = (c: Client): State =>
  c.pending.reduce((s, m) => mutators[m.name](s, m.args), c.authoritative)

That last function is your sentence “frontend state is a projection over the state with the commands applied” made literal. Projection is Fowler's word from event sourcing — a read model computed from a log — and it is the right one. The discipline it imposes is the whole design: the UI never writes to authoritative. If it does, the rebase step below has nothing to rewind to.

The loop: push, pull, rebase

Zero's documentation lays out one mutation's life in six steps. The user acts; the mutator runs locally and the mutation is appended to pending; the projection changes and the UI updates — this is the optimistic display. In the background the client pushes pending mutations in batches to the server. The server runs its copy of each mutator, in a transaction, and records the client's new last mutation id. The change replicates to whatever serves reads. Finally the client pulls: it sends its cookie, gets back a patch to a newer authoritative state, and the new last mutation id.

Then the step that gives the design its name. Replicache:

“it rewinds the state of the Client View to the last version it got from the server, applies the patch to get to the state the server currently has, and then replays any pending mutations on top.”
function onPull(c: Client, pull: { patch: Patch; cookie: string; lastMutationID: number }): Client {
  return {
    authoritative: applyPatch(c.authoritative, pull.patch),        // 1. rewind + apply patch
    cookie: pull.cookie,
    pending: c.pending.filter(m => m.id > pull.lastMutationID),    // 2. drop what the server has processed
  }
  // 3. `view(c)` replays what is left on top — the rebase. Nothing to do; it is derived.
}

This is a rebase in exactly git's sense: the base moved, your commits are replayed on the new base. And as in git, the replay can produce something different from the first run:

“It's possible and common for mutations to calculate a different effect when they run during rebase.”

Replicache's example is a calendar invite: the mutator booked room 4 optimistically, but by the time the pull arrives someone else has room 4 in the authoritative state, so the replay books room 5 or records an error. The mutation did not change. The state it ran against did. This is why a mutation records the call and not the effect.

One integer, three jobs

Per client, the server stores one number: the last mutation id it has processed. Replicache's server guide is specific about how it is used, and each use is a distributed-systems problem quietly solved:

Deduplication. “It's common due to connectivity issues for clients to send a mutation which has already been processed. Skip these.” A mutation whose id is not greater than the stored value is dropped. So a push can be retried freely — the mutation id is doing the job of an idempotency key, without a separate header.
Ordering. Ids are sequential per client. A mutation whose id is ahead of expected means one was skipped: “If the Replicache client is working correctly, this can never happen. If it does there is nothing to do but return an error.” Per-client order is a protocol invariant, not a hope.
Confirmation. The pull returns it, and the client drops every pending mutation at or below it. That is what turns your "committing" into your "committed": not an ack per mutation, but a high-water mark.

One constraint holds all three together: “all of these changes must happen atomically in a single transaction for each mutation in a push.” The mutator's writes and the bump to the last mutation id commit together, or neither does. If they could separate, a crash in between would leave a mutation applied but unrecorded, and the client's retry would apply it twice.

Rejection — where your instinct and the engines part

In calibration you said: if the server rejects a mutation, later pending mutations should fail too, because they “might be dependent on that command's semantics.” That is a coherent design. It is not what Replicache, Zero, Figma or TanStack DB do by default, and the reason is worth understanding before you decide whether to override it.

The server side first. Replicache's guide advances the last mutation id even when the mutator fails, and says so: “Handle errors inside mutations by skipping and moving on. This is convenient in development but you may want to reconsider as your app gets close to production.” The alternative — leaving the id where it was — means the client's next push contains the same mutation, which fails the same way, forever. A rejected mutation must still count as processed, or the log never drains.

The client side follows from the rebase. When the pull arrives, the rejected mutation's id is at or below the new high-water mark, so it is dropped from pending like any other. Its optimistic effect disappears because the projection is recomputed without it — Zero describes this as the pending mutation's local effect being rolled back and replaced by the authoritative rows. The mutations after it are still pending. They replay, each on its own, against the new authoritative state.

So the engines' default is independent replay: no mutation knows about the one before it. The dependency you were worried about is real, and it is handed to the mutator. A deleteCard that runs after its renameCard was rejected still finds the card and deletes it. A renameCard that runs after its createCard was rejected finds nothing, and a well-written mutator does nothing. Whether that outcome is what the user meant is the open problem; the engines only promise that it is consistent.

Synthesis, flagged. No source I found treats “what should happen to dependent pending mutations” as a first-class question. Three positions exist in practice: (1) independent replay with precondition-checking mutators — the engines' default; (2) grouping dependent mutations into one unit that succeeds or rolls back together — TanStack DB's transaction is this shape; (3) your position, abort the rest of the log on first rejection, which nothing ships out of the box. Lesson 05 works through the trade.

Exercise 1 · Execution

Order the life of one mutation

Zero's six steps, shuffled. Click them into order. One of the seven cards does not belong in the sequence at all — leave it out.

Exercise 2 · Bug hunt

Click the line that breaks the protocol

Four fragments of a client or server. One is clean. For each, ask: what does this do to the rebase, or to the retry?

Exercise 3 · Scenario

The calibration question, again

Offline, a user renames a card (mutation 7), then deletes it (mutation 8). Online, the server rejects 7 on a name-length rule and accepts 8. Write what the user sees, in order, under the engines' default. Then write one sentence on whether you would ship that.

Exercise 4 · Recall

From memory

Write before revealing.

Glossary — added this lesson

Mutator — a named function that changes state; the same name runs on client and server. Replicache
Mutation — a record of one mutator call: id, name, arguments. The call, not the effect. Replicache
Pending (optimistic, unacknowledged) — a mutation the server has not yet confirmed processing. Your "dirty" and "committing" are both this. Replicache / Figma
Server-authoritative state — the only state that counts; the client's copy is the Client View at a cookie. Replicache / Zero
Projection — a read model computed from a log; here, pending.reduce(apply, authoritative). Fowler
Rebase — rewind to the last authoritative state, apply the server's patch, replay pending mutations on top. Replay may compute a different effect. Replicache
Last mutation id — the server's per-client high-water mark; deduplicates retries, enforces order, and confirms. Replicache
Independent replay — the default rejection semantics: a rejected mutation is dropped and counted as processed; later pending mutations replay on their own. synthesis of Replicache / Zero behaviour

What this unlocks

Everything here assumed the server can put mutations in one order, and that this order is the truth. Lesson 02 asks what "order" means when two clients edit the same field while disconnected — and why the answer is not the clock on the wall. Lesson 03 asks what you would need if there were no server to decide at all.

Status map Lesson 02 →

Sources: Replicache, How it works · Replicache, Remote mutations · Zero, Writing data · TanStack DB, Overview · Wallace, How Figma's multiplayer technology works · Fowler, Event Sourcing · RESOURCES.md