Home › Distributed systems › Replication
algorithm medium

CRDTs

Problem Replicas in several regions, offline-first apps and collaborative editors must accept writes locally without coordination and still end up with identical data. This avoids conflicts and lost updates when replicas merge.

Data types whose replicas accept writes independently and always converge by merging, with no coordination: counters, sets, maps, registers, text.

merge O(state) for state-based, O(1) per op for op-based; metadata O(replicas) per object
replicationeventual-consistencyoffline-firstcollaborationmerge
Overview

If merge is commutative, associative and idempotent, replicas can exchange state in any order, any number of times, and still converge, so every write is local. The craft is state whose merge means something: a counter keeps one slot per replica and takes the max; a set tags each add with a unique dot so a remove cancels only adds it has seen.

Key facts

Use when

  • Multi-region writes, offline-first apps, collaborative editing, presence: databases (Riak, Redis Enterprise), real-time presence (Phoenix), collaborative documents (Yjs, Automerge).

Avoid when

  • No meaningful automatic merge exists, or you need transactions: Raft.

Deep dive

How it works

Parts:

  • replica state — counter slots, dotted sets or sequence IDs, one copy per replica
  • merge function — commutative, associative, idempotent join of two states
  • version vector / dots — records which updates a replica has seen
  • delivery layer — gossip or sync that moves states (or deltas) between replicas

Operations: (1) apply a write locally, update the dots; (2) send state or a delta to a peer in any order; (3) merge on receipt; (4) read the value from the merged state.

  • G-Counter: map actor → count; increment your own slot; merge = pointwise max; value = sum.
  • PN-Counter: two G-Counters (increments, decrements); value = P − N.
  • LWW-Register: (timestamp, actor, value); merge keeps the largest (timestamp, actor). Loses concurrent writes.
  • MV-Register: keeps all concurrent values, tagged with vector clocks; the app resolves.
  • OR-Set (add-wins), optimized "ORSWOT": a version vector clock of all dots seen plus, per element, the dots of its live adds. Add creates a fresh dot (actor, ++clock[actor]). Remove deletes the element's dots locally, and the clock remembers they existed. On merge, a dot present on one side only survives if the other side's clock has not seen it (it's new) and is dropped if it has (it was removed). No tombstones.
  • Sequences (text): each character gets a unique, ordered ID (RGA, YATA in Yjs, Fugue); deletes leave tombstones or are compacted.
A: add(x) dot a1          B: (has seen a1) remove(x)
merge(A, B): dot a1 only on A, B.clock[a] >= 1 → B removed it → x gone
concurrent add(x) on A as a2: B.clock[a] = 1 < 2 → survives → add wins

Code

use std::collections::{BTreeSet, HashMap};
use std::hash::Hash;

pub type Actor = u32;
pub type Dot = (Actor, u64);

#[derive(Clone, Default, Debug, PartialEq)]
pub struct GCounter { slots: HashMap<Actor, u64> }

impl GCounter {
    pub fn inc(&mut self, me: Actor, n: u64) { *self.slots.entry(me).or_default() += n; }
    pub fn value(&self) -> u64 { self.slots.values().sum() }
    pub fn merge(&mut self, o: &GCounter) {
        for (a, &c) in &o.slots { let s = self.slots.entry(*a).or_default(); *s = (*s).max(c); }
    }
}

#[derive(Clone, Default, Debug, PartialEq)]
pub struct PNCounter { p: GCounter, n: GCounter }

impl PNCounter {
    pub fn add(&mut self, me: Actor, d: i64) {
        if d >= 0 { self.p.inc(me, d as u64) } else { self.n.inc(me, d.unsigned_abs()) }
    }
    pub fn value(&self) -> i64 { self.p.value() as i64 - self.n.value() as i64 }
    pub fn merge(&mut self, o: &PNCounter) { self.p.merge(&o.p); self.n.merge(&o.n); }
}

/// Add-wins observed-remove set without tombstones (ORSWOT).
#[derive(Clone, Debug, PartialEq)]
pub struct OrSet<E: Eq + Hash + Clone> {
    clock: HashMap<Actor, u64>,            // every dot this replica has seen
    entries: HashMap<E, BTreeSet<Dot>>,    // live add-dots per element
}

impl<E: Eq + Hash + Clone> OrSet<E> {
    pub fn new() -> Self { OrSet { clock: HashMap::new(), entries: HashMap::new() } }
    fn seen(clock: &HashMap<Actor, u64>, (a, c): Dot) -> bool { clock.get(&a).map_or(false, |&m| c <= m) }

    pub fn add(&mut self, me: Actor, e: E) {
        let c = self.clock.entry(me).or_default();
        *c += 1;
        // The new dot supersedes every add of e this replica has observed.
        self.entries.insert(e, BTreeSet::from([(me, *c)]));
    }
    pub fn remove(&mut self, e: &E) { self.entries.remove(e); } // clock keeps the evidence
    pub fn contains(&self, e: &E) -> bool { self.entries.contains_key(e) }

    pub fn merge(&mut self, o: &OrSet<E>) {
        let mut out: HashMap<E, BTreeSet<Dot>> = HashMap::new();
        let keys: Vec<E> = self.entries.keys().chain(o.entries.keys()).cloned().collect();
        let empty = BTreeSet::new();
        for e in keys {
            if out.contains_key(&e) { continue; }
            let mine = self.entries.get(&e).unwrap_or(&empty);
            let theirs = o.entries.get(&e).unwrap_or(&empty);
            let mut keep: BTreeSet<Dot> = mine.intersection(theirs).copied().collect();
            keep.extend(mine.difference(theirs).filter(|d| !Self::seen(&o.clock, **d)));
            keep.extend(theirs.difference(mine).filter(|d| !Self::seen(&self.clock, **d)));
            if !keep.is_empty() { out.insert(e, keep); }
        }
        for (a, &c) in &o.clock { let s = self.clock.entry(*a).or_default(); *s = (*s).max(c); }
        self.entries = out;
    }
}

Dots per actor are contiguous because every replica generates its own dots in sequence and merges whole state; that is what lets a plain version vector stand in for the set of seen dots.

Complexity and constants

  • G/PN-Counter: 8-16 bytes per actor; merge O(actors).
  • ORSWOT: O(actors) clock + one dot (~12 bytes) per live element, typically one per element after merges settle; merge O(elements + actors).
  • Text CRDTs: per-character IDs and tombstones; modern implementations (Yjs, Automerge 2, diamond-types) run-length encode consecutive inserts by one actor, so the saved format of real editing traces costs order of bytes, not tens of bytes, per character.
  • Delta-state: send only the join-irreducible changes since the last ack: bandwidth proportional to updates, not to object size.

Efficiency

  • Cost: counters take 8-16 bytes per actor and merge in O(actors); an ORSWOT set takes one dot (about 12 bytes) per live element and merges in O(elements + actors). Text CRDTs run-length encode consecutive inserts by one actor, so saved real editing traces cost order of bytes per character (see above). Bound: metadata size and merge bandwidth, not CPU.
  • Levers:
    • delta-state shipping — bandwidth proportional to the update, not the object (Almeida et al., JPDC 2018) — large sets and maps
    • run-length encoded text IDs — order of bytes per character instead of tens — editors
    • few stable actor IDs — clocks stay O(actors) — long-lived replicas
    • ORSWOT instead of tombstoned OR-Set — no tombstones for removed elements (Bieniusa et al. 2012) — churning sets
    • compact tombstones after all replicas have seen a delete — bounded text metadata — sequence types
  • Naive → standard → state of the art:
    • ship full state every time · O(object size) per sync
    • op-based with causal delivery · O(1) per op, needs exactly-once causal delivery
    • delta-state with run-length encoded IDs · O(update) per sync, order of bytes per character

Implementation notes

  • Actor IDs: a replica that loses its state and restarts with the same actor ID reissues old dots, which merges then silently drop. Persist the counter or take a fresh actor ID on recovery.
  • Keep the actor set small: route client writes through a few server-side actors, or prune actors after a causal-stability point (all replicas have seen their dots).
  • Ship deltas with anti-entropy via gossip; state-based merge makes duplicate and out-of-order delivery harmless.
  • Compose: a map of CRDTs is a CRDT (Riak maps, Automerge documents). Removing a map key while another replica updates the nested value needs explicit "reset-remove" or "add-wins" semantics.
  • Use hybrid logical clocks for LWW timestamps to bound skew effects.

Pitfalls

  • Using LWW everywhere: concurrent updates silently vanish; fine for "last profile edit", wrong for carts.
  • Expecting a counter to support set(0): concurrent increments make reset ill-defined without a reset-aware design.
  • Op-based CRDTs over a transport without causal delivery: a remove can arrive before its add.
  • Unbounded tombstones in sequence CRDTs for long-lived docs; plan compaction or snapshots.
  • Interleaving anomalies in text: two users typing at the same spot can interleave characters with some algorithms (RGA); Fugue was designed to avoid this.

Variants and alternatives

Operational transformation (OT)central server transforms ops (Google Docs); simpler metadata, needs a server.
Bounded counter / escrowCRDT plus rights transfer to keep a non-negative invariant.
Quorum replication with siblingsstore concurrent values, resolve in the app.Raftwhen you actually need agreement.

In production

Databases: Riak KV data types (riak_dt), Redis Enterprise Active-Active, SoundCloud Roshi (LWW-element-set on Redis). Real-time presence: Phoenix Presence (Elixir, delta-CRDT). Collaborative editing: Automerge, Yjs, Loro.

Implementations

namelangrepoinstallverdictlicencehealthas_ofnotes
yrsRusty-crdt/y-crdtcargo add yrs————Yjs-compatible, YATA text
automergeRustautomerge/automergecargo add automerge————JSON-like documents, history; JS and Rust bindings
loroRustloro-dev/lorocargo add loro————text (Fugue), lists, maps, trees
YjsJavaScriptyjs/yjsnpm install yjs————most used web CRDT

Sources

  • Marc Shapiro, Nuno Preguiça, Carlos Baquero, Marek Zawirski, Conflict-free Replicated Data Types, SSS 2011. paper
  • Marc Shapiro et al., A Comprehensive Study of Convergent and Commutative Replicated Data Types, INRIA RR-7506, 2011. report
  • Annette Bieniusa et al., An Optimized Conflict-free Replicated Set, arXiv 1210.3368, 2012. paper
  • Paulo Sérgio Almeida, Ali Shoker, Carlos Baquero, Delta State Replicated Data Types, JPDC 2018. paper · arXiv
  • Matthew Weidner, Martin Kleppmann, The Art of the Fugue: Minimizing Interleaving in Collaborative Text Editing, arXiv 2023. paper

Builds on

Used by

Systems (17):

Alternatives and related