Every time I start a sync engine I spend the first week on transport, and every time that turns out to be the week that did not matter.
Transport is solved
Batching, retries, resumable cursors, a version vector so you know where you left off:
type Cursor = { replica: string; clock: number }
async function pull(since: Cursor): Promise<Op[]> {
// page until the server says it has nothing newer
}
There are a dozen good write-ups on all of it, and the code ends up looking the same regardless of who wrote it.
Conflict policy is your product
The part that is actually yours is what happens when two people edited the same thing. Last write wins is a decision. A CRDT merge is a decision. Both are the same amount of engineering, and only one of them matches what the user expects.
Which one that is depends on the field, not the record:
| Field | Policy | Why |
|---|---|---|
title | Last write wins | Nobody co-authors a task title |
completed | Sticky true | Losing a completion erodes trust in the whole app |
position | Fractional index | Anything else flickers the list forever |
notes | Text CRDT | Two people typing is the normal case |
The field is the unit of policy, not the record.
What changed for me
Once I started writing the merge rules first and the transport second, the engines got smaller. The rules table above is usually the first file in the repo now, before there is anything to sync at all.