Conflict-free replicated data types (CRDTs)
Open in the interactive app: practice, quiz, mock interview →In one minute
- A CRDT (conflict-free replicated data type) is a data structure that replicas can update independently, offline, with no coordination, and still converge to the same value once they have seen the same updates.
- Convergence is guaranteed by math, not by a leader: merges are commutative, associative, and idempotent, so order and duplication of delivery do not matter.
- You pick a CRDT per data shape: counters, registers, sets, maps, sequences for text. Each one bakes in a conflict policy such as add-wins or last-writer-wins.
- The costs are metadata growth (tombstones, version vectors, unique IDs) and semantics that may not match the business rule, such as an inventory count going negative.
How it works
State-based CRDTs
Each replica holds a state, and a merge function computes the least upper bound of two states. Replicas periodically ship full state (or deltas) to each other. Because merge is idempotent and commutative, gossip, retries, and reordering are harmless. The classic G-Counter stores a map from replica ID to count; a replica increments only its own entry; merge takes the per-entry max; the value is the sum. A PN-Counter is two G-Counters, one for increments and one for decrements. Shapiro and colleagues' catalogue defines these and proves each one converges (Shapiro et al.).
Operation-based CRDTs
Replicas broadcast operations instead of state. Operations that may run concurrently must commute, and the delivery layer must deliver each operation exactly once and usually in causal order. Messages are small, but the transport is more demanding. Delta-state CRDTs sit in between: a delta mutator returns a small state fragment that merges like a full state, so they get small messages and keep the forgiving transport of state-based designs (Almeida, Shoker and Baquero).
Registers and sets
A LWW-Register keeps the value with the highest (timestamp, replicaId). It is simple and lossy: concurrent writes silently drop all but one. A multi-value register keeps all concurrent values, tracked with version vectors, and lets the application resolve them, as Riak's siblings did.
Sets need a policy for concurrent add and remove of the same element. An OR-Set (observed-remove) tags every add with a unique ID. A remove deletes only the tags it has observed. A concurrent add carries a tag the remove never saw, so the element survives: add wins.
Sequences for collaborative text
Text editing needs a list CRDT such as RGA, YATA (used by Yjs), or Fugue, which was designed so that two users typing at the same spot do not get their passages interleaved (Weidner and Kleppmann). Each character gets a unique, ordered identifier, for example (lamportClock, replicaId), and insertions reference the identifier of the character to their left. Deletes leave tombstones so later inserts can still find their anchor. Modern implementations compress runs of characters typed in sequence, which cuts metadata from dozens of bytes per character to a few bytes per run.
Why it converges
For state-based CRDTs, states form a join-semilattice: there is a partial order, and merge returns the smallest state at least as large as both inputs. Every update moves the state upward. Any two replicas that have received the same set of updates have the same least upper bound, regardless of path.
Down the stack
Layer 1The contract
CRDTs promise strong eventual consistency: any two replicas that have received the same set of updates are in the same state, in any delivery order, with duplicates, and without any coordination. Every replica accepts writes locally while partitioned, so local latency is a memory write. What you do not get is just as precise. There is no global invariant across replicas (two replicas can each spend the last unit of a budget), no guarantee that the merged result matches what either user intended, and no bound on staleness beyond "when the updates arrive". Operation-based CRDTs additionally require the transport to deliver every operation exactly once in causal order; state-based ones only need eventual delivery.
Layer 2The mechanism
Yjs, one of the most widely used text CRDTs, stores a document as a linked list of items. Each item has an ID (clientID, clock), the IDs of its left and right neighbours at insertion time (origin and rightOrigin), and its content. The YATA rules use those origins to place concurrent inserts deterministically. Consecutive characters typed by one client merge into a single item, so a paragraph typed in one go is one struct. A replica summarises what it has as a state vector, a map from clientID to the next expected clock. Sync is two steps: send your state vector, receive exactly the items you lack. Deletions are kept as a compact delete set of (clientID, clock, length) ranges, and deleted content can be garbage collected while the item's ID and length stay as an anchor (Yjs internals). Deletes usually come in runs, so the delete set stays tiny: for a benchmark trace with 182,000 inserts and 77,000 deleted characters it is about 4.5 KB.
Automerge takes a different shape: every change is hashed and lists the hashes of the changes it depends on, forming a DAG like a git history. Since version 2 the core is written in Rust, and documents are saved in a columnar binary format where each property of the operations (actor, counter, insert position, value) is a separate column compressed with run-length and delta encoding, and each change is identified by the SHA-256 hash of its bytes (Automerge binary format). Automerge 3.0 (July 2025) keeps that compressed form in memory too instead of expanding it on load: pasting Moby Dick into a document took about 700 MB in Automerge 2 and about 1.3 MB in Automerge 3 (Automerge 3.0).
On the server side, Redis Software's Active-Active databases (formerly called CRDB) accept writes in every participating cluster and resolve conflicts only with CRDT types, using vector clocks for strong eventual consistency (Redis docs). Riak shipped counters, sets, maps, registers and flags as built-in types; since Basho went into receivership in 2017 Riak has been maintained by its open-source community (Wikipedia), so weigh that before adopting it.
Layer 3The machine
Metadata cost is the engineering problem. A naive design that gives each character a JavaScript object with an ID, two origin pointers and a deleted flag spends well over 50 bytes of heap per character before counting the character itself, and a linked list of objects scattered across the heap misses CPU cache on nearly every step, at roughly 100 ns per miss to DRAM. Run-length merging fixes both: one item for a run of typed text, and integer fields encoded as variable-length integers that take 1 to 3 bytes for typical values. Real editing traces compress to close to the size of the text itself: a trace of about 260,000 operations whose final text is 107,121 bytes saves as 129,062 bytes in Automerge 2.0, full history included (Automerge 2.0).
On disk the usual pattern is an append-only log of small binary updates, often a few dozen bytes each, written as users type, then periodically merged into one compacted snapshot so load time stays bounded. Tombstones and per-client entries in state vectors never fully disappear, so a long-lived document with thousands of short-lived client IDs grows its version metadata even when the visible text does not.
Why it's built this way
- Coordination costs a round trip that offline and high-latency clients cannot pay, so every replica must accept writes alone and fix conflicts by math, not by asking.
- Merge must give the same result regardless of order or duplication, so it is defined as a join over a semilattice: commutative, associative and idempotent.
- That requires never forgetting what was seen, so IDs, tombstones and version vectors accumulate.
- Accumulated metadata would dwarf the data, so implementations compress runs, encode integers compactly, and garbage collect deleted content while keeping its anchors.
Diagram
flowchart LR
A0["A: {A:0, B:0}"] --> A1["A increments: {A:2, B:0}"]
B0["B: {A:0, B:0}"] --> B1["B increments: {A:0, B:3}"]
A1 --> M1["A merges B: {A:2, B:3}"]
B1 --> M1
A1 --> M2["B merges A: {A:2, B:3}"]
B1 --> M2
M1 --> V["Both read 5"]
M2 --> VWorked example
A shopping cart service runs in 3 regions with active-active writes and 100 ms cross-region latency. Each cart is an OR-Map from product ID to a PN-Counter for quantity.
A user adds 2 mugs on a phone routed to region A. At the same moment their laptop, served by region B, removes the mug line that it saw from yesterday. After replication, A's add carries a fresh tag that B's remove did not observe, so the mug line survives with quantity 2. With last-writer-wins instead, whichever request had the later timestamp would win, and a 50 ms clock skew could decide it. The OR-Set result matches what users expect from a cart.
Metadata cost: each add tag is about 16 bytes (replica ID plus counter). A cart with 20 lines and a history of 200 adds and removes holds tags only for live adds, about 320 bytes, if removes discard observed tags. Tombstones are where costs grow, which is why systems either garbage-collect them once all replicas have acknowledged (requiring known membership) or use designs that avoid them.
For a collaborative document of 100,000 characters, a naive sequence CRDT with 32 bytes per character costs 3.2 MB of metadata. Run-length and columnar encoding in Yjs or Automerge bring the saved form close to the text size, and Automerge 3 keeps it compact in memory as well.
In the wild
- Riak, 2013: last-writer-wins drops most concurrent writes. Jepsen ran Riak with
allow_mult=false, which resolves conflicts by timestamp. During a partition it lost 1,815 of 1,985 acknowledged writes, and 1,434 of 2,000 even on a healthy cluster; stricter read and write quorums did not help. Withallow_mult=trueand siblings merged by set union, no writes were lost. The advice was to merge with an associative, commutative, idempotent function, which is a CRDT (Jepsen). - Riot Games, 2016: friend lists as application-level CRDTs. League of Legends chat stored rosters in Riak with siblings enabled and a replication factor of 3 to 5. When two players sent Alice a friend invite at the same moment from different chat servers, Riak kept both versions, and the next read merged the operation logs so both invites survived. Each roster is encoded as a CRDT, then JSON, then compressed (Riot Games).
- Notion, 2025: CRDTs arrive with offline mode. Offline editing had been Notion's most requested feature for years. Pages a user marks for offline use are migrated on demand to a new CRDT data model for conflict resolution, and the local SQLite cache was turned from best effort into persistent storage that holds everything those pages depend on (Notion).
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| State-based CRDT | Tolerates loss, duplication, reorder | Shipping large states | Gossip replication between servers |
| Op-based CRDT | Small messages | Needs exactly-once causal delivery | Reliable ordered pipe already exists |
| LWW-Register | Tiny, simple | Silently loses concurrent writes | Values where any recent write is fine |
| Multi-value register | Nothing lost | App must resolve siblings | Data where losing writes is unacceptable |
| Consensus or single leader | Arbitrary invariants, exact semantics | Coordination latency, no offline writes | Balances, uniqueness, inventory limits |
Pitfalls in production
- Invariants that CRDTs cannot keep, such as "stock never below zero" or "usernames unique". Use coordination (escrow, reservations, or consensus) for those.
- Tombstones grow without bound in long-lived documents. Plan garbage collection with causal stability or periodic snapshots that rebase history.
- LWW with wall clocks drops writes under skew. Use hybrid logical clocks, or a multi-value register where losses matter.
- Replica IDs reused after a device reinstall break uniqueness of tags. Generate fresh random IDs per install or session.
- Interleaving anomalies in text: two users typing at the same spot can have characters interleaved, a problem that affects both CRDTs and OT and went unnoticed for decades. Use algorithms designed against it, such as Fugue (Weidner and Kleppmann).
Leadership lens
- CRDTs are worth it when offline or multi-region active-active writes are a requirement, not a nice-to-have. Otherwise a single leader is simpler to reason about.
- Ask what conflict policy each field gets, and have product sign off on it; "add wins" is a product decision.
- Prefer a mature, maintained library (Automerge, Yjs, or Redis Software's Active-Active types) over hand-rolled merges, and fuzz merge order in tests.
- Budget for storage growth and compaction from day one, and measure document load time at the 99th percentile size.
Check yourself
Why must a state-based merge be idempotent?
Show answer
Gossip and retries deliver the same state more than once; idempotence ensures re-merging changes nothing.
In an OR-Set, why does a concurrent add survive a remove?
Show answer
The remove deletes only the tags it observed, and the concurrent add created a new tag the remover never saw.
Why can a PN-Counter not enforce "never below zero"?
Show answer
Two replicas can each decrement the last unit concurrently without coordination; the invariant needs agreement before the decrement.
What is the main extra requirement of operation-based CRDTs?
Show answer
A delivery layer that delivers every operation exactly once and in causal order.
Sources and further study
Primary
- Almeida, Shoker and Baquero, Delta State Replicated Data Types: delta mutators that give state-based CRDTs small messages without losing tolerance of unreliable channels.
- Weidner and Kleppmann, The Art of the Fugue: defines maximal non-interleaving for text and the Fugue algorithms that achieve it.
Systems docs
- Yjs INTERNALS.md: items, origins, run merging, state vectors, delete sets and garbage collection.
- Automerge binary format specification: the columnar, run-length and delta-encoded layout and the change hash graph.
- Redis Software: Active-Active geo-distributed Redis: CRDT-only conflict resolution across clusters, with optional causal consistency.
- Riak KV: Data Types: counters, sets, maps, registers and flags, and why removes need a causal context.
Books
- Designing Data-Intensive Applications (1st ed.), Martin Kleppmann, Ch 5 "Replication": multi-leader and leaderless replication, concurrent writes and automatic conflict resolution.
- Understanding Distributed Systems (2nd ed.), Roberto Vitillo, chapter "Coordination avoidance": broadcast, CRDTs and Dynamo-style stores.
Other curricula
- Kleppmann, Concurrent and Distributed Systems lecture notes (Cambridge): builds operation-based and state-based CRDTs from causal broadcast, then contrasts them with OT.
- donnemartin/system-design-primer: mentions only that conflict resolution gets harder as master-master write nodes are added; it does not cover CRDTs.
Watch
- CRDTs: The Hard Parts (Martin Kleppmann): interleaving, moves, and the edge cases in text and tree CRDTs.
- CRDTs and the Quest for Distributed Consistency (InfoQ): Kleppmann's introduction to how CRDTs work and why.
Free resources
- A comprehensive study of CRDTs: Shapiro et al.'s catalogue of counters, registers, sets, and graphs with proofs.
- crdt.tech: community hub of papers, talks, and implementations.
- Local-first software (Ink & Switch): why CRDTs matter for offline-capable apps, with practical lessons.
- A Conflict-Free Replicated JSON Datatype: how nested maps and lists compose into a JSON CRDT.
- 5000x faster CRDTs (Joseph Gentle): how real implementations make sequence CRDTs fast and compact.