Skip to content

    System Design

    CRDTs and Conflict Resolution: Merging Edits Without a Coordinator

    How CRDTs let replicas converge without coordination: state and operation delivery, conflict policies, sequence CRDTs and their costs, plus a TypeScript lab.

    By Published 12 min read

    Share

    Standard lessonadvancedTypeScript labBackend System Design

    Before this: Idempotent and Commutative Operations, Logical and Vector Clocks

    What you'll learn: By the end of this lesson you will be able to define strong eventual consistency, compare state-based and operation-based delivery, choose a conflict policy for a kind of data, explain how a sequence CRDT orders concurrent inserts with unique ids and tombstones, name its costs and limits, and run a TypeScript lab that shows each of them.

    A shopping list is taped to the fridge, and a photo of it sits on every phone in the family. In a lift with no signal, one person crosses out "eggs" and another writes "bread" under "milk". Back online the phones must agree. "The later save replaces the list" loses an edit. "Line 3 means line 3" misplaces the new item, because lines shift as people edit. A conflict-free replicated data type (CRDT) has the rules for combining concurrent edits built into its definition, so every replica that has received the same edits shows the same list, whatever the order of arrival.

    The small types (counters, last-writer-wins register, observed-remove set) and the three merge laws are in Idempotent and Commutative Operations; ordering tools are in Logical and Vector Clocks. This lesson covers delivery styles, conflict policy and the hard case, the ordered list.

    Verified against Shapiro et al. (INRIA RR-7687, 2011) and Almeida's survey (arXiv 2310.18220, version 2), read on 2026-11-08. Lab run on Node 26.12.1 with Vitest 5.1.0.

    The core idea

    A CRDT makes convergence a property of the data type, so replicas accept writes locally, without coordination, and still end in the same state. Shapiro and colleagues call this strong eventual consistency (Definition 2.3): updates reach every correct replica, and correct replicas that have delivered the same updates have equivalent state. They contrast it with systems that apply an update, find the conflict later and roll back, which in general needs consensus so all replicas arbitrate alike.

    SEC says every replica reaches the same answer, not that it is the answer a user meant. Choosing the answer is the design work.

    Three illustrations, each labelled: a minimal example (two authors typing into one list, in "Build it"), a realistic example (a shared household list in ShopStream) and failure examples (interleaved typing, a reused replica id).

    Two ways to carry the same update

    In state-based designs replicas send state and the receiver merges. If states form a join-semilattice (any two have a least upper bound), merge computes that bound and updates only move state upward, replicas converge given eventual delivery (Theorem 2.1). In operation-based designs replicas send operations. Convergence assumes causal delivery (each message reaches every recipient exactly once, in an order consistent with happened-before) and that concurrent updates commute (Theorem 2.2). The authors state such a broadcast needs no consensus and may deliver concurrent updates in any order. Either style can emulate the other (Theorems 3.1 and 3.2).

    sequenceDiagram accTitle: Two ways to bring replicas together accDescr: In the operation-based style, replica A sends each operation to B, which must apply it after its dependencies and once only. In the state-based style, A sends its state from time to time and B merges it with a union that tolerates loss, repeats and reordering. participant A as Replica A participant B as Replica B Note over A,B: Operation-based A->>B: operation (insert x after y) Note over B: apply after its dependencies, once only Note over A,B: State-based A->>B: state (or the part B lacks) Note over B: merge, repeats and reordering are harmless

    Figure 1. The same update, carried two ways. How to read this: the top half sends one edit and puts the burden on delivery (order, no duplicates). The bottom half sends knowledge and puts the burden on the merge function, which is why it survives a lossy network.

    Question Operation-based State-based
    Network needs Reliable, usually causal delivery; exactly once for non-idempotent operations None: tolerates loss, duplication, reordering
    Participants A known group; merging two independent groups is hard Unknown or changing; groups can merge
    Long partition or offline Undelivered messages must be buffered, growing with the partition Each replica is autonomous
    Message size Small Larger, unless only the missing part is sent

    Almeida (section 7) gives this comparison, and adds that "CRDTs are types with commutative operations" is a misconception: the operation-based model exists to handle non-commutative operations such as a set's add and remove, with a side-effect-free prepare at the source and an effect designed to commute for concurrent operations.

    Conflict resolution is a choice of meaning

    For a concurrent add and remove of one element, Shapiro et al. list several options (add wins, remove wins, the highest replica address wins) and note every one satisfies strong convergence. The type fixes the choice, so it must match what the data means.

    Kind of data Policy Cost
    A profile field Last writer wins on (timestamp, replica id) One concurrent write is silently dropped
    A set (cart, tags) Add wins: a remove cancels only the adds it saw A remove can lose to a concurrent add
    Both writes matter Multi-value register: keep the concurrent values, let the reader choose Readers handle several values
    Ordered list or text Sequence CRDT with unique element ids Metadata, tombstones, interleaving
    A limit (stock, balance floor, seat) Not a CRDT job: coordinate Refusal or waiting in a partition

    The last row follows from the definition. A CRDT operation never waits for other replicas (Almeida, section 2.4), so no replica can know another just took the last unit. Two replicas that each decrement a counter at zero both succeed and merge to minus one; the crdt-basics lab README describes this for its PN-counter. The CAP lesson puts money and uniqueness on the strict path.

    flowchart TD accTitle: Choosing how to resolve concurrent writes accDescr: A decision flow. If a wrong merge breaks a limit or uniqueness rule, one authority decides. Otherwise, if losing a concurrent write is acceptable, use last writer wins. If not and the data is a set, use an add-wins set. If it is ordered, use a sequence CRDT. Otherwise keep all values and let a person choose. S["Two replicas changed the same data"] --> Q1{"Does a wrong merge break a limit or a uniqueness rule?"} Q1 -->|"yes"| AUTH["One authority decides: coordination, not a CRDT"] Q1 -->|"no"| Q2{"Is losing one concurrent write acceptable?"} Q2 -->|"yes"| LWW["Last writer wins on (timestamp, replica id)"] Q2 -->|"no"| Q3{"Is it a set or a map of keys?"} Q3 -->|"yes"| SET["Add-wins set or map"] Q3 -->|"no"| Q4{"Is it ordered, like text?"} Q4 -->|"yes"| SEQ["Sequence CRDT"] Q4 -->|"no"| MV["Keep all values, let a person choose"]

    Figure 2. A decision per field. How to read this: start at the top for each field of your model, not for the whole system. One product usually uses several branches.

    Why a list is harder than a counter

    A list has positions, and positions shift when anyone inserts before them. Operational transformation (OT) rewrites a remote operation's index before applying it. Almeida says it is manageable under central coordination (his example is Google Docs) and dauntingly complex for peer-to-peer replication, where early transformation functions were later found to violate their own correctness properties.

    The CRDT answer is to stop using indexes. Each element gets a globally unique, immutable id and an operation says "insert after the element with this id". Designs like Treedoc, Logoot and LSEQ generate an id between neighbours, and ids grow. Designs like RGA, Woot and YATA use small ids, a pair (replica, counter) that Almeida calls a dot, plus ordering rules, and a delete leaves a tombstone. Per the survey, Automerge is based on RGA and Yjs on a modified YATA. The lab implements an RGA-style rule:

    // from src/opReplica.ts, #integrate(): start just after the element this insert follows,
    // then skip every node with a greater id; ids are (counter, replica name), counter first
    while (i < this.#nodes.length && compareIds(this.#nodes[i]!.id, op.id) > 0) i++;

    A new element's counter exceeds every counter its author has seen, so it outranks the element it follows. Two inserts after one element are concurrent, and the greater id goes first on every replica. That rule is the whole policy for concurrent inserts. The state-based version of the lab reaches the same order differently: it walks the tree of "inserted after" links, children greatest id first.

    sequenceDiagram accTitle: Three concurrent edits converge on one list accDescr: Replicas a, b and c share the list ac. Concurrently, a inserts b after the first character, b inserts X after it, and c deletes it. After the operations are delivered in a different order at each replica, every replica shows Xbc. participant A as Replica a participant B as Replica b participant C as Replica c Note over A,C: all three hold "ac" A->>A: insert b after 1@a, id 3@a B->>B: insert X after 1@a, id 3@b C->>C: delete 1@a Note over A,C: operations delivered in a different order at each replica Note over A,C: every replica shows "Xbc", 4 nodes, 1 tombstone

    Figure 3. The lab's first scenario. How to read this: the three edits are concurrent. The delete keeps 1@a as a tombstone, so the two inserts that follow it still have an anchor. Greater id first puts X (3@b) before b (3@a), then the older c (2@a).

    Build it

    Goal. Run both delivery styles on one list type and see convergence under shuffled and repeated deliveries, then the costs.

    Setup. examples/frontend/labs/rga-replicated-list (pure TypeScript, seeded randomness), a sibling of examples/frontend/labs/crdt-basics (counters, register, OR-set). From the package folder:

    pnpm test          # 22 tests
    node src/demo.ts   # six scenarios, deterministic output

    How it works. OpReplica holds an array of nodes, applies the rule above and buffers operations whose anchor has not arrived. StateReplica holds two grow-only sets (all inserts, all deleted ids), merges by union and computes the text from the tree. The tests run 150 seeded sessions: operation-based replicas end identical with an empty buffer, the state-based replica gets the same text from the same operations in any order, union is commutative, associative and idempotent, and the two replica types agree.

    Output of node src/demo.ts, captured on Node 26.12.1 (2026-11-08):

    === 1. Three replicas share the list "ac", then edit at the same time
    shared start: a="ac" b="ac" c="ac"
    a: insert 'b' as 3@a after 1@a  -> a sees "abc"
    b: insert 'X' as 3@b after 1@a  -> b sees "aXc"
    c: delete 1@a  -> c sees "c"
    now each replica receives the other two operations, in a different order each:
      a receives delete 1@a -> applied, text "bc"
      a receives insert 'X' as 3@b after 1@a -> applied, text "Xbc"
      b receives insert 'b' as 3@a after 1@a -> applied, text "aXbc"
      b receives delete 1@a -> applied, text "Xbc"
      c receives insert 'X' as 3@b after 1@a -> applied, text "Xc"
      c receives insert 'b' as 3@a after 1@a -> applied, text "Xbc"
    converged: true (all three show "Xbc"; each keeps 4 nodes, 1 of them a tombstone)
    
    === 2. Operations that arrive before the element they refer to
    a typed insert 'x' as 1@a after start, then insert 'y' as 2@a after 1@a; the network delivers y first
      with a buffer   : receive y -> buffered (waiting 1), receive x -> applied (waiting 0), text "xy"
      without a buffer: receive y -> dropped, receive x -> applied, text "x"; a shows "xy"
    
    === 3. The same operations as state: set union, any order, no buffer
    applied in issue order    : "Xbc"  (4 inserts, 1 tombstone)
    applied in reverse order  : "Xbc"  (4 inserts, 1 tombstone)
    every operation sent twice: "Xbc"  (4 inserts, 1 tombstone)
    first three operations alone "abc"; the other three alone "" (they follow an element that half does not hold, so nothing is reachable yet)
    merge(h1,h2) -> "Xbc"; merge(h2,h1,h1) -> "Xbc"; same state: true
    
    === 4. 100 random sessions (seeds 1 to 100): 3 replicas, 60 steps, shuffled and repeated deliveries
    operations issued: 3220; deliveries that had to wait for an element: 1578; repeated deliveries: 587; most operations any replica held back at once: 14
    operation-based replicas ended identical and with nothing waiting: 100 of 100
    state-based replica fed the same operations shuffled showed the same text: 100 of 100
    replicas without a buffer: 100 of 100 sessions ended with different texts (1882 operations dropped in total)
    
    === 5. Two authors type at the same place: forwards, then backwards
    forwards : a typed "ABC", b typed "123" -> a "X123ABC", b "X123ABC"
    backwards: a typed "ABC", b typed "123" -> a "X1A2B3C", b "X1A2B3C"
    
    === 6. Tombstones: deleting does not free memory, and freeing it early is unsafe
    typed 40 characters, deleted 30: visible 10, nodes kept 40, tombstones 30
    both replicas hold the tombstone for 'a'; b's insert of 'b' after it is still in flight: a "c", b "bc"
    a purges the tombstone (1 node freed), then the in-flight insert arrives -> buffered
    a "c" but b "bc"; operations a is still holding back: 1

    Section 1 is Figure 3 for real: intermediate texts differ (aXbc, Xc), the end state does not. In section 2 an early operation must wait; without the buffer it is lost. Section 3 needs no buffer, at the price of keeping every insert and tombstone. In section 4, 1,578 deliveries of 3,220 operations had to wait and 587 were repeats; every session converged, and replicas with no buffer diverged in all 100. Section 5 is the interleaving anomaly, section 6 the tombstone cost.

    Break it. In src/opReplica.ts comment out the skip loop. Six tests fail (concurrent inserts, both typing tests, 150-session convergence, five replicas, agreement with the state-based replica) because each replica now orders concurrent inserts by arrival. Then reverse the child sort in src/stateReplica.ts: one test fails, since the two replica types now disagree. Both were run.

    Stretch. Track a version vector and purge a tombstone only when its delete is causally stable. Add a delta mode that sends only what the other side lacks.

    What the model leaves out. One id per character, linear anchor search, no formatting or moves, no network, no authorization, and no line-by-line check against published RGA pseudo-code.

    Tombstones, metadata and garbage collection

    Almeida notes tombstones make state grow without bound, and removing one safely relies on causal stability: a message is stable at a replica when every message delivered afterwards will be causally after it, so no concurrent operation referring to it can still arrive. That is stronger than "every replica has the delete", as demo section 6 shows. For pure operation-based designs he notes operations stop becoming stable during a partition, which costs memory, not availability. Causal CRDTs keep a dot store and a causal context instead of tombstones. Identity costs too: Almeida calls the growth of replica-id maps the main obstacle to scaling CRDTs to many participants.

    Two ways it goes wrong

    Interleaving. Almeida quotes an example from Kleppmann et al.: two users insert " Alice" and " Charlie" at one place in "Hello!" and many sequence CRDTs converge on "Hello Al Ciharcliee!". He reports it in Logoot, LSEQ, Treedoc and Woot, a lesser form in RGA when typing backwards, and a CRDT (Fugue) built to avoid it. The lab reproduces the RGA case: X1A2B3C. Convergence is the easy part; defining the intended outcome is hard.

    Reused ids. Uniqueness is assumed everywhere. In a throwaway script against the lab, a replica a restored from an old backup typed y as 1@a while b already held x as 1@a. b treated it as a duplicate and ignored it: a showed y, b showed x, and nothing repairs it. The fix is outside the CRDT: a restore always gets a new replica id.

    The other side of this topic

    On the client this is local-first editing and offline queues. The Frontend System Design track plans a lesson on CRDTs and operational transformation (not published yet, so no link).

    In practice: a shared household list in ShopStream

    This is the realistic example, an educational scenario on ShopStream. Nothing in it describes a real company, and the feature and every figure are illustrative.

    This is a realistic scenario, not a case history.

    The situation

    ShopStream wants a shared list: a household keeps one ordered shopping list edited from several phones, including in a shop with weak signal. The first prototype saves the whole list as one document on each change, so two members editing at once overwrite each other.

    Starting architecture

    Shared list: phones hold replicas, the server relays and storesPhone A to Sync service (operations); Phone B to Sync service (operations); Sync service to List store (append); Sync service to Checkout (send to cart)Phone A: local replicaPhone Alocal replicaPhone B: local replicaPhone Blocal replicaSync service: checks editorsSync servicechecks editorsList store: operationsList storeoperationsCheckout: stock, paymentCheckoutstock, paymentoperationsoperationsappendsend to cart

    Figure 4. The shared-list design. How to read this: phones apply edits at once and send operations in the background. The sync service authenticates, stores and relays; it does not edit. Anything with a limit goes to checkout, which re-checks it.

    Options considered

    Option Behaviour Cost Risk
    A. Whole-document last writer wins Simple One writer's edits vanish Lost items
    B. Server assigns every edit its place (central OT style) Predictable Edits wait for the server; offline is hard Server in every edit
    C. Sequence CRDT per list, relayed by the server Local, offline friendly Metadata, tombstones, delivery layer Interleaving, untrusted operations

    The decision

    Option C for the list, strict handling for limits. The list holds names and notes, where a merged outcome is fine. "Send to cart" copies items into the cart and re-validates stock and price at checkout, since a CRDT cannot know the last unit is gone. The sync service checks that the sender may edit, validates each operation's shape (a forged operation would merge like any other), and issues every install and every restore a fresh replica id. "Clear list" starts a new list instead of deleting items one by one, which bounds tombstones.

    Rollout and verification

    Run the new lists beside the old documents for a few households and compare. Verification mirrors the lab: shuffled and repeated operation order ends identical, and replaying the stored log rebuilds the same list. Metrics: buffered operations, nodes against visible items, sync lag.

    The 2 a.m. failure

    Support reports a list two family members see differently. Check operation counts and highest counters per replica id, and look for one replica id on two devices. The cause here is a phone restored from backup with its old id, so its new items reused ids and were ignored as duplicates. Mitigation: issue a new id, rebuild the phone's view from the store, re-enter the lost items. Lasting fix: a restore always means a new replica id.

    Architect's notes

    • Write the merge meaning per field before choosing a library.
    • Keep limits off the CRDT: stock, balances and uniqueness need one authority and a re-check.
    • Treat operations as untrusted input: authorize and validate before relaying.
    • Plan metadata: track nodes against visible items and decide how tombstones are reclaimed.
    • Offline-heavy clients suit state-based or delta propagation; known, well-connected groups suit operation-based (Almeida's summary).

    Common mistakes

    • Assuming a CRDT decides what a conflict means. It guarantees agreement, not intent.
    • A CRDT counter for a limited resource. Both replicas can spend the last unit.
    • Dropping operations that arrive early. Hold them, or use state-based delivery.
    • Purging tombstones because every replica has them. An operation in flight can still refer to one.
    • Reusing a replica id, or using indexes in operations.

    Key takeaways

    • A CRDT guarantees strong eventual consistency without coordination.
    • State-based merge tolerates loss, repeats and reordering; operation-based delivery needs reliable, usually causal delivery.
    • Every conflict policy converges; pick the one that matches the data.
    • Lists use unique element ids and tombstones, not indexes.
    • Tombstone removal needs causal stability.
    • Convergence does not prevent interleaving, and a CRDT cannot enforce a limit.

    Practice exercises

    Exercise 1 (30 min). For each demo section, name the theorem or assumption (eventual delivery, causal delivery, commutativity, union as least upper bound) it illustrates.

    Exercise 2 (45 min). For five fields of a food-delivery app (driver status, order notes, favourites, wallet balance, delivery checklist) pick a policy from Figure 2 and say what is lost.

    Exercise 3 (1 hour). Run both "Break it" experiments and write which assumption each removed.

    Exercise 4 (2 hours). Add delta(since) to StateReplica and show merging only deltas converges on the seeded sessions.

    Exercise 5 (2 hours). Design the tombstone-reclaim rule for the household list, including a phone offline for a month.

    Official documentation

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments