CRDTs: Conflict-Free Replicated Data Types

State-based (CvRDT) and op-based (CmRDT) CRDTs, G-Counter, PN-Counter, OR-Set, LWW-Register, and how Yjs, Automerge, Figma, and Redis use them.

3.5advanced 25 min 3,693 words Updated 2026-05-11

TL;DR: A CRDT is a data structure whose replicas can be updated concurrently on different nodes, without coordination, and are mathematically guaranteed to converge once all updates are delivered. This property is called Strong Eventual Consistency (SEC)[1]. State-based CRDTs (CvRDTs) merge full states via a join-semilattice; op-based CRDTs (CmRDTs) broadcast commutative operations over causal delivery. CRDTs power Redis Enterprise Active-Active, Yjs (~4M npm downloads/week[2]), and Automerge; Figma's multiplayer engine is CRDT-inspired but uses a centralized server-orders-events approach rather than true CRDTs[3]. The trade-off: you pay metadata overhead and give up sequential consistency to get availability with zero coordination.

Learning Objectives#

After this module, you will be able to:

  • Distinguish CvRDTs (state-based) from CmRDTs (op-based) and explain when each applies
  • Implement G-Counter, PN-Counter, and OR-Set from first principles
  • Choose a CRDT for a given use case (counters, sets, text, JSON documents)
  • Explain why CRDTs achieve strong eventual consistency without consensus
  • Recognize the costs: metadata growth, tombstones, garbage collection complexity
  • Articulate why LWW-Register silently loses data and when to avoid it

Intuition#

Imagine two accountants in different cities, each keeping a ledger for the same company. Throughout the day, both record transactions independently. At closing time, they call each other and merge their books. If both use a simple rule, "for each account, take the higher balance," they will always agree on the final state without arguing about whose entry came first. No coordinator needed. No lock. No waiting.

Now imagine a collaborative whiteboard. Two designers draw strokes simultaneously. Each stroke gets a unique ID. When the strokes sync, neither erases the other because they are tagged differently. The whiteboard converges to "both strokes present" without conflict resolution logic.

That is the CRDT idea: embed the conflict resolution into the data structure itself, so replicas converge by construction. Where Raft and Paxos pay latency to get linearizability, CRDTs pay metadata and accept weaker ordering to get availability with zero coordination. This is a genuinely different regime from consensus.

Theory#

Strong Eventual Consistency (SEC)#

Plain eventual consistency promises only that replicas will "eventually" converge. It says nothing about what happens when two replicas have received the same set of updates but in different orders. Application developers must write custom conflict resolution, which is error-prone.

SEC closes that gap with a safety guarantee: any two replicas that have delivered the same set of updates are in identical states[1:1]. No "eventually" qualifier, no application-level merge code. The data type handles it.

Shapiro, Preguica, Baquero, and Zawirski formalized SEC in their 2011 INRIA study[1:2] and showed two sufficient conditions:

  1. State-based (CvRDT): replicas exchange full state and merge using a commutative, associative, idempotent join that forms a join-semilattice. Every update moves the state monotonically upward in the lattice.
  2. Op-based (CmRDT): replicas broadcast operations via reliable causal delivery, and concurrent operations commute.

Both forms achieve SEC without consensus. Replicas remain available under arbitrary network partitions, meeting AP in the CAP sense[4].

State-based CRDTs (CvRDTs)#

A CvRDT replica holds a lattice value S. Updates move S upward. Merge takes the least upper bound (LUB) of two states. Because the merge is commutative, associative, and idempotent, replicas converge regardless of message order, duplication, or how many times they merge.

G-Counter (grow-only counter): a vector of per-replica monotonic integers. Each replica increments only its own slot. The value is the sum of all slots. Merge takes the elementwise max[1:3].

Python
# G-Counter: the canonical state-based CRDT
state = {}  # map<replica_id, int>, all start at 0

def value(s):
    return sum(s.values())

def increment(s, me):
    s[me] = s.get(me, 0) + 1

def merge(a, b):
    return {r: max(a.get(r, 0), b.get(r, 0))
            for r in set(a) | set(b)}
Replica A Replica B Replica C merge merge merge A:3, B:2, C:5value = 10 A:3, B:0, C:0value = 3 A:0, B:2, C:0value = 2 A:0, B:0, C:5value = 5

Three replicas increment independently; merge takes the elementwise max, and the global value is the sum of the merged vector.

PN-Counter: a pair (P, N) of G-Counters. Increments go to P, decrements go to N. Value = sum(P) - sum(N). Merge applies G-Counter merge to each half independently[1:4].

G-Set: grow-only set. Merge = union. You can add but never remove.

2P-Set: adds plus a tombstone set for removes. Once removed, an element cannot be re-added. This limitation motivates the OR-Set.

OR-Set (Observed-Remove Set): each add tags the element with a unique identifier. A remove only removes the tags the remover has observed. A concurrent add on another replica uses a fresh tag and survives[1:5].

Tip

If you understand why the G-Counter merge (elementwise max) is a join-semilattice, the rest of the zoo is just variations on the same idea: find a merge function that is commutative, associative, and idempotent.

Trade-offs of state-based CRDTs:

  • Pro: works over any transport (gossip, TCP, UDP with retries). Duplicates and reordering are harmless.
  • Con: shipping full state is expensive as replicas grow. Delta-state CRDTs (Almeida, Shoker, Baquero 2018) fix this by shipping only incremental deltas that still form a semilattice[5].

Op-based CRDTs (CmRDTs)#

Instead of shipping state, CmRDTs broadcast operations. The middleware delivers operations in causal order, at least once, and exactly once per replica. Because concurrent operations commute, the delivery order for non-causally-ordered pairs does not matter[1:6].

State-based (CvRDT) Op-based (CmRDT) gossip full state gossip full state op1 op2 delivered in causal order Replica A state merge = LUB Replica B state Converged state Replica A Causal broadcast Replica B Converged state

State-based ships whole states over any transport; op-based ships individual operations over a causal-broadcast channel.

Op-based OR-Set: each add(e) generates a unique tag. remove(e) broadcasts only the tags the remover observed. A concurrent add with a fresh tag is never erased.

add("x") tag=a1 ADD(x, a1) state = {(x,a1)} remove("x") observed={a1} add("x") tag=a2 REMOVE(x, {a1}) state = {} state = {(x,a1),(x,a2)} state = {(x,a2)} x survives: concurrent add used a fresh tag Replica A Replica B

A concurrent add uses a fresh tag, so a prior remove cannot erase it; this is why OR-Sets converge without data loss.

Trade-offs of op-based CRDTs:

  • Pro: low bandwidth per update (tens of bytes vs full state). Fine-grained edit history enables undo and time travel[6][7].
  • Con: requires reliable causal broadcast. In practice that means a backplane (Redis Streams, Kafka, a custom WebSocket fanout, or a peer-to-peer protocol).

Text and rich collaboration#

Collaborative text editing is the hardest CRDT problem. Each character needs a stable identifier so concurrent inserts at the same position converge. The field evolved through multiple generations:

  • WOOT (Oster et al., 2006): the first text CRDT. Characters have predecessor/successor pointers[8].
  • Logoot (Weiss, Urso, Molli, 2009): variable-length position vectors. Suffers interleaving anomalies on concurrent prepends[9].
  • RGA (Roh et al., 2011): a causal tree where each insert references an existing character ID. Automerge uses RGA[10].
  • YATA (Jahns, 2016): the algorithm behind Yjs. Similar to RGA with a different tie-break for concurrent inserts. Jahns discovered that a flat-list insertion-sort implementation is vastly faster than tree-based approaches[10:1][6:1].
  • Peritext (Litt, Lim, Kleppmann, van Hardenberg, 2022): a rich-text CRDT that anchors formatting spans (bold, italic, links) to character operation IDs rather than unstable indexes, so concurrent formatting and edits merge with intent preservation[11].

The performance gap between implementations is staggering. On the B4 benchmark (259,778 operations, 104,852 final characters)[12]:

LibraryApply timeEncoded sizeNotes
Yjs 13.65,714 ms159,929 bytes~1.5 bytes/char
Automerge 2.114,326 ms129,116 bytesRust + WASM
Automerge 0.14~500,000 ms~146 MBPure JS, pre-rewrite (on-disk)[7:1]
Diamond (Rust)56 ms-5,196x faster than Automerge 1.0[10:2]
Important

Do not invent your own text CRDT. The Peritext paper shows that even Notion's pre-Peritext implementation discarded one of two concurrent paragraph edits[11:1]. Use Yjs or Automerge.

Costs and pitfalls of CRDTs#

CRDTs are not free. The metadata cost is the primary engineering challenge:

Tombstones and identifiers. OR-Sets, RGA, and text CRDTs keep identifiers (and often full operations) forever so that late-arriving removes know what they target. Automerge 0.14 used ~4 KB peak RAM per keystroke on the B4 trace[7:2]. Automerge 2.0 reduced on-disk encoding size by 1,135x through columnar compression[7:3]. Automerge 3.0 further cut runtime memory by over 10x in general and dramatically more in some cases (pasting Moby Dick: 1.3 MB vs 700 MB in 2.0, ~538x)[13].

Garbage collection. You can only discard metadata for operations that are "causally stable," meaning all replicas have seen them. Determining causal stability requires knowing which replicas exist and what they have received. This is non-trivial in open-membership systems.

LWW-Register: the CRDT that eats your data. LWW attaches a timestamp to every write and merge keeps the greatest. Two concurrent writes? One is silently discarded. Clock skew can reverse the "real" order. Kleppmann's local-first essay warns that LWW silently loses data under concurrent writes[4:1].

Delta-state CRDTs (Almeida, Shoker, Baquero 2018) offer a middle ground: they transmit incremental deltas that are still in the semilattice, giving op-like bandwidth with state-like transport tolerance[5:1]. Akka Distributed Data uses this approach in production.

Real-World Example#

Yjs: the CRDT engine behind AFFiNE, Evernote, GitBook, and Tiptap.

Yjs is a JavaScript CRDT library that exceeds 4,000,000 npm downloads per week as of May 2026[2:1]. It powers collaborative editing in AFFiNE, Evernote, GitBook, Tiptap, Huly, Cargo, JupyterLab, Proton Docs, and dozens of other products[2:2][14]. (Notion, by contrast, uses its own custom block-level conflict resolution system, not Yjs.)

The architecture is deceptively simple. Yjs stores the document as a flat list of items. Each item is (content, id=[clientID, clock], originLeft, originRight, deleted?). On insert, Yjs finds the origin item, scans forward using the YATA conflict rule, and splices the new item in[10:3].

The trick that makes it fast: run-length encoding. A paste of 1,000 characters becomes one item with length 1,000, not 1,000 items. An in-memory cache of the last edit index makes the common case ("user keeps typing") O(1). Kevin Jahns "wrote and rewrote parts of Yjs 12 times" to hit these performance numbers[10:4].

Yjs provides shared types (Y.Map, Y.Array, Y.Text, Y.XmlFragment) and is network-agnostic: connection providers (y-websocket, y-webrtc, y-redis) handle sync without a mandatory server. Delete operations are stored as a compressed bitmap, not per-item tombstones, keeping the "insert then delete same string" document size at 38 bytes[12:1].

The result: on the B4 academic-paper editing trace, Yjs encodes 104,852 final characters in 159,929 bytes, roughly 1.52 bytes per character[12:2]. The entire library is ~20 KB gzipped. Compare this to Automerge 0.14, whose on-disk encoding for the same document was 146,406,415 bytes (~146 MB), a ~915x size ratio that motivated Automerge's complete rewrite[7:4].

ops via y-websocket ops via y-websocket ops via y-webrtc state vector exchange Client 1Y.Doc Sync Serverawareness + doc state Client 2Y.Doc Client 3Y.Doc PersistenceLevelDB / Redis

Yjs clients sync via pluggable providers; the server holds document state for persistence and late-joining clients, but the CRDT merge happens on every replica independently.

Trade-offs#

Implementation style#

ApproachProsConsBest whenOur Pick
CvRDT (state-based)Works over any transport; duplicates harmlessFull state per merge is costly for large structuresSmall states, gossip protocolsStart here for simplicity
CmRDT (op-based)Low bandwidth per update; fine historyNeeds reliable causal broadcast infrastructureA backplane (Redis Streams, Kafka) is availableUse when bandwidth matters
Delta-stateSmall messages + unreliable transport toleranceBuffering and ack complexityLarge replica state, lossy networkAdvanced optimization once state-based hits its bandwidth ceiling

Picking a CRDT for your data#

Data typeCRDTProsConsOur Pick
CountersPN-CounterIncrement and decrement without coordinationMetadata is O(replicas) per counterDefault choice for multi-writer counters
Sets (add/remove/re-add)OR-SetIntuitive; concurrent add survives concurrent removeTombstones need GC under causal stabilityDefault choice for sets
Registers (single value)MV-Register (multi-value)Preserves concurrent writes as siblings the app resolvesApp must handle siblingsWhen losing a concurrent write is unacceptable
Rich text / documentsRGA (Automerge) or YATA (Yjs)Proven rich-text collaboration at scale; character-level mergeComplex internals; DIY implementations are prone to known anomalies (Logoot interleaving, pre-Peritext formatting bugs)Yjs for performance, Automerge for history; do not roll your own

Common Pitfalls#

Warning

Last-writer-wins silently drops concurrent writes. LWW-Register attaches a timestamp to every write and keeps the greatest on merge. Two replicas accepting writes to the same key within the replication window produce one timestamp-winning write; the other is discarded with no user-visible signal (Shapiro et al., 2011; Kleppmann, "Local-first software," 2019). Wall-clock skew across nodes can even reverse the "real" order. For collaborative documents, shared files, carts, or anything where a lost write matters, use an OR-Set, an MV-Register that preserves siblings, or an RGA/Yjs/Automerge document instead. Accept LWW only for truly low-stakes fields like last_seen_at or ephemeral presence.

Warning

Rolling your own text CRDT. Logoot has interleaving anomalies on concurrent prepends. Naive Markdown-based rich text breaks on overlapping bold/italic. The Peritext paper demonstrates that even Notion's pre-Peritext implementation discarded concurrent paragraph edits[11:2]. Use Yjs or Automerge.

Warning

Tombstones with no GC plan. Documents edited heavily over time become slow to load even if the current visible state is small. Monitor document-size to final-content-size ratio. A healthy Yjs document is ~1.5x the final text; Automerge 0.14 was over 1,000x[12:3][7:5].

Warning

Op-based without causal broadcast infrastructure. CmRDTs assume causal, exactly-once delivery. A raw pub/sub without dedupe is insufficient. Use a broker that provides causal order (Kafka with a single partition per document) or stamp operations with version vectors for dedup and buffering.

Warning

Naive metadata overhead (10 to 100x data size). Every keystroke is a permanent record in naive implementations. Run-length encode consecutive inserts (Yjs), use columnar binary formats (Automerge 2.0+), and garbage-collect causally stable entries.

Warning

Mixing wall-clock timestamps across replicas for LWW. NTP skew of tens of milliseconds across regions is easy to hit. Use hybrid logical clocks (HLCs) for tie-breaking, not wall-clock timestamps. Better yet, avoid LWW entirely for multi-region multi-writer keys.

Exercise#

Design the state layer for a collaborative to-do app that works offline. Users can add, remove, and re-add tasks, mark them done, and edit task text. All changes must merge cleanly when replicas reconnect. Pick CRDTs for each field (title, done flag, assignees, order) and describe the sync protocol.

Hint

Think about what operation each field needs. A task title is text (sequence of characters). A done flag is a boolean that can flip back and forth. Assignees are a set where items can be added and removed. Task order needs a stable position that does not collide. The task list itself needs add and remove with re-add support.

Solution

Top-level structure: a Y.Map (or Automerge document) keyed by task ID.

Task list membership: OR-Set semantics. Each task has a unique ID generated client-side. Adding a task inserts the ID; removing it removes only observed tags. Re-adding creates a fresh tag, so it survives concurrent removes.

Per-task fields:

FieldCRDTRationale
titleRGA / Y.TextCharacter-level merge; two users editing different words never conflict
doneLWW-Register (with HLC)Boolean toggle; concurrent flips are rare and low-stakes; last flip wins
assigneesOR-SetUsers can be added and removed; concurrent add survives concurrent remove
orderFractional index (Figma-style)Each task stores a position between its neighbors; no reindexing needed

Sync protocol:

  1. Each client maintains a local Y.Doc (or Automerge doc).
  2. On edit, the client applies the operation locally (instant UI response).
  3. When online, the client exchanges state vectors with the server via y-websocket.
  4. The server computes the delta between the client's state vector and the stored document, sends missing operations.
  5. On reconnect after offline, the same state-vector exchange catches the client up. Merge is automatic via the CRDT join.

This design gives offline-first editing with guaranteed convergence. The only field where data can be "lost" is done (LWW), but toggling a checkbox is low-stakes enough to accept last-writer-wins.

Key Takeaways#

  • CRDTs give strong eventual consistency without coordination. This is a genuinely different regime from consensus protocols.
  • State-based CRDTs (CvRDTs) are easier to ship because they need no causal broadcast. Op-based CRDTs (CmRDTs) are bandwidth-efficient but demand more infrastructure.
  • The G-Counter (vector of per-replica counters, merge = elementwise max) is the mental model for all CRDTs: find a join-semilattice.
  • Do not invent your own text CRDT. Use Yjs (performance-optimized, 20 KB gzipped) or Automerge (history-preserving, Git-like branching).
  • LWW-Register is the CRDT people reach for by accident. It is the one that silently loses data under concurrent writes.
  • Tombstones and metadata grow without bound unless you plan for garbage collection under causal stability.
  • Production CRDTs (Redis Enterprise Active-Active, Yjs, Automerge) hide CRDT semantics behind familiar APIs. The math is invisible to application developers.

Further Reading#

Flashcards#

QWhat is Strong Eventual Consistency (SEC)?

AAny two replicas that have delivered the same set of updates are in identical states, without requiring coordination. SEC is achieved by CRDTs through commutative merge operations.

QWhat are the two families of CRDTs?

AState-based (CvRDT): replicas exchange full state and merge via a join-semilattice. Op-based (CmRDT): replicas broadcast commutative operations over reliable causal delivery.

QHow does a G-Counter work?

AEach replica increments only its own slot in a vector. The value is the sum of all slots. Merge takes the elementwise max across replicas. This is commutative, associative, and idempotent.

QWhy can an OR-Set re-add an element after removal?

AEach add tags the element with a unique identifier. Remove only removes observed tags. A concurrent add on another replica uses a fresh tag that the remove never saw, so it survives.

QWhy is LWW-Register dangerous?

AIt silently discards one of two concurrent writes. Clock skew can reverse the "real" order. It is the CRDT that loses data by design.

QWhat does delta-state CRDT solve?

AIt gives op-like bandwidth (small messages) with state-like transport tolerance (no causal broadcast required). Deltas are still in the semilattice, so correctness is preserved.

QWhy should you not write your own text CRDT?

AText CRDTs have subtle interleaving anomalies (Logoot), formatting merge failures (pre-Peritext), and performance cliffs (Automerge 0.14 was 5,000x slower than optimized implementations). Use Yjs or Automerge.

QHow does Yjs achieve ~1.5 bytes per character overhead?

ARun-length encoding of consecutive inserts by the same client, compressed delete-set bitmaps instead of per-item tombstones, and a last-edit-location cache for O(1) common-case inserts.

QWhat infrastructure does an op-based CRDT require that a state-based one does not?

AReliable causal broadcast: operations must be delivered in causal order, at least once, and exactly once per replica. This typically requires a backplane like Kafka, Redis Streams, or a custom WebSocket fanout.

QName three production systems using CRDT semantics.

ARedis Enterprise Active-Active (OR-Set for sets, PN-Counter for INCR, LWW for strings), Yjs (YATA algorithm powering AFFiNE, Evernote, GitBook, Tiptap), and Riak KV 2.0+ (native counters, sets, maps, registers, flags).

QWhat is causal stability and why does it matter for CRDT garbage collection?

AAn operation is causally stable when all replicas have received it. Only then can its metadata (tombstones, identifiers) be safely discarded. Determining causal stability requires knowing which replicas exist and what they have seen.

QHow did Automerge 2.0 achieve a 1,135x reduction in on-disk size vs 0.14?

AColumnar compression: grouping same-typed fields (actor IDs, counters, keys, values) across all operations into parallel arrays, then compressing each column with variable-length integer encoding (LEB128).

References#

  1. Shapiro, Preguica, Baquero, Zawirski. "A comprehensive study of Convergent and Commutative Replicated Data Types." INRIA Research Report RR-7506, 2011. https://inria.hal.science/inria-00555588/en ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  2. "Yjs in the Wild" (user list) and npm weekly downloads (https://www.npmjs.com/package/yjs; ↩︎ ↩︎ ↩︎

  3. Evan Wallace, "How Figma's multiplayer technology works," Figma Blog, October 16, 2019. https://www.figma.com/blog/how-figmas-multiplayer-technology-works/ ↩︎

  4. Kleppmann, Wiggins, van Hardenberg, McGranaghan. "Local-first software: you own your data, in spite of the cloud." Onward! 2019. https://www.inkandswitch.com/essay/local-first/ ↩︎ ↩︎

  5. Almeida, Shoker, Baquero. "Delta State Replicated Data Types." Journal of Parallel and Distributed Computing, Volume 111, January 2018, pp. 162-173. arXiv:1603.01529. https://arxiv.org/abs/1603.01529 ↩︎ ↩︎

  6. Yjs documentation. https://docs.yjs.dev/ ↩︎ ↩︎

  7. Ink & Switch. "Introducing Automerge 2.0." January 2023. https://automerge.org/blog/automerge-2/ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  8. Oster, Urso, Molli, Imine. "Data consistency for P2P collaborative editing" (WOOT). CSCW 2006. https://dl.acm.org/doi/10.1145/1180875.1180916 ↩︎

  9. Weiss, Urso, Molli. "Logoot: A Scalable Optimistic Replication Algorithm for Collaborative Editing on P2P Networks." ICDCS 2009. https://doi.org/10.1109/ICDCS.2009.75 ↩︎

  10. Seph Gentle. "5000x faster CRDTs: An Adventure in Optimization." July 31, 2021. https://josephg.com/blog/crdts-go-brrr/ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  11. Litt, Lim, Kleppmann, van Hardenberg. "Peritext: A CRDT for Rich-Text Collaboration." Ink & Switch, Nov 2021; CSCW 2022. https://www.inkandswitch.com/peritext/ ↩︎ ↩︎ ↩︎

  12. Kevin Jahns (dmonad). CRDT benchmarks repository (B1-B4, B4x100). https://github.com/dmonad/crdt-benchmarks ↩︎ ↩︎ ↩︎ ↩︎

  13. Ink & Switch. "Automerge 3.0." July 2025. https://automerge.org/blog/automerge-3/ ↩︎

  14. Yjs GitHub README (comprehensive user list including AFFiNE, Evernote, GitBook, Tiptap, Huly, Cargo, JupyterLab, Proton Docs). https://github.com/yjs/yjs ↩︎