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 said | Replicache / Zero | TanStack DB | Figma |
|---|---|---|---|
| command (the function) | mutator | handler | — |
| command (one entry in the log) | mutation | mutation, inside a transaction | edit |
| dirty | pending | optimistic state | unacknowledged |
| committing | pushed, not yet confirmed | persisting | unacknowledged |
| committed / "state from the backend" | server-authoritative state; the Client View at a cookie | synced data | — |
| state + commands applied | rebase | "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:
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:
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.
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
pending.reduce(apply, authoritative). FowlerWhat 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.
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