Problem Statement: The Nightmare of Concurrent Editing
Six months ago, our team migrated our collaborative document editor—powering roughly 15,000 active daily workspaces—away from centralized Redis locks to an offline-first architecture. Anyone who has engineered real-time collaboration engines for apps like Google Docs or Figma knows the nightmare scenario: two users make edits offline or across flaky 350ms mobile connections, and their document states instantly diverge.
Historically, teams leaned on Operational Transformation (OT) or pessimistic locking. Centralized locks destroy offline support entirely. OT made early collaborative web apps possible, but it still demands an authoritative central server to sequence and transform every keystroke. When we added direct peer-to-peer WebRTC sync, OT’s n-way transformation matrix quickly became unmanageable. Edge-case reconnects frequently mangled user paragraphs.
We needed something simpler: a deterministic data structure. Every replica should mutate state locally and merge with any peer at any time, in any order, without data loss or server arbitration. That requirement led us straight to Conflict-free Replicated Data Types (CRDTs).
Core Concepts: Mathematical Foundations of CRDTs
Under the hood, a state-based CRDT (CvRDT) relies on a mathematical concept called a bounded join-semilattice. Automatic convergence requires a merge function that satisfies three properties:
- Commutativity:
merge(A, B) == merge(B, A). Network packet arrival order never affects the final document state. - Associativity:
merge(merge(A, B), C) == merge(A, merge(B, C)). Batching updates or routing through intermediate hops produces the identical result. - Idempotence:
merge(A, A) == A. Replaying dropped packets or duplicate sync messages causes zero side effects.
These mathematical guarantees simplify distributed debugging. As long as your merge logic satisfies all three laws, concurrent states will converge predictably.
CRDT Categories
CRDTs generally follow one of two patterns:
- State-based (CvRDT): Nodes transmit their entire state (or state deltas) to peers, resolving differences with a pairwise merge operator.
- Operation-based (CmRDT): Nodes broadcast individual mutation operations over a reliable, causally-ordered transport layer.
Collaborative text requires sequence CRDTs. Each character receives a globally unique ID and a fractional position identifier between its immediate neighbors. This guarantees concurrent insertions never collide, even when two users type at the exact same index simultaneously.
Hands-on Practice: Building CRDTs in Python
Let’s build two functional CRDTs in pure Python: a state-based Positive-Negative Counter (PN-Counter) to illustrate state joins, followed by a sequence document engine for collaborative text.
1. State-Based PN-Counter (Grow-Only / Decrement Counter)
A distributed counter cannot simply hold a single integer. Concurrent increments and decrements do not commute naturally. Instead, each node maintains two separate grow-only maps: one for increments (P) and one for decrements (N).
from typing import Dict
class PNCounter:
def __init__(self, node_id: str):
self.node_id = node_id
self.p_matrix: Dict[str, int] = {}
self.n_matrix: Dict[str, int] = {}
def increment(self, value: int = 1) -> None:
self.p_matrix[self.node_id] = self.p_matrix.get(self.node_id, 0) + value
def decrement(self, value: int = 1) -> None:
self.n_matrix[self.node_id] = self.n_matrix.get(self.node_id, 0) + value
@property
def value(self) -> int:
total_positive = sum(self.p_matrix.values())
total_negative = sum(self.n_matrix.values())
return total_positive - total_negative
def merge(self, remote: 'PNCounter') -> None:
# Commutative, Associative, Idempotent join via max()
all_p_keys = set(self.p_matrix.keys()).union(remote.p_matrix.keys())
for key in all_p_keys:
self.p_matrix[key] = max(self.p_matrix.get(key, 0), remote.p_matrix.get(key, 0))
all_n_keys = set(self.n_matrix.keys()).union(remote.n_matrix.keys())
for key in all_n_keys:
self.n_matrix[key] = max(self.n_matrix.get(key, 0), remote.n_matrix.get(key, 0))
2. Building a Collaborative Text Document Engine
In collaborative text editing, rigid array indices break down immediately. If Alice and Bob both type at index 0, standard list operations overwrite data. In our sequence CRDT, every character carries a fractional position, a high-resolution timestamp, a node ID, and a tombstone flag for deletions.
import time
import uuid
from dataclasses import dataclass
from typing import List, Optional
@dataclass
class CharNode:
id: str
position: float
char: str
timestamp: float
node_id: str
deleted: bool = False
class CollaborativeDocument:
def __init__(self, node_id: str):
self.node_id = node_id
self.elements: List[CharNode] = []
def _find_position(self, index: int) -> float:
active_chars = [c for c in self.elements if not c.deleted]
if not active_chars:
return 1.0
if index <= 0:
return active_chars[0].position / 2.0
if index >= len(active_chars):
return active_chars[-1].position + 1.0
prev_pos = active_chars[index - 1].position
next_pos = active_chars[index].position
return (prev_pos + next_pos) / 2.0
def insert(self, index: int, char: str) -> CharNode:
pos = self._find_position(index)
node = CharNode(
id=str(uuid.uuid4()),
position=pos,
char=char,
timestamp=time.time(),
node_id=self.node_id,
deleted=False
)
self.elements.append(node)
self._sort_elements()
return node
def delete(self, index: int) -> Optional[CharNode]:
active_chars = [c for c in self.elements if not c.deleted]
if 0 <= index < len(active_chars):
target = active_chars[index]
target.deleted = True
target.timestamp = time.time()
return target
return None
def _sort_elements(self) -> None:
# Deterministic ordering: position -> timestamp -> node_id
self.elements.sort(key=lambda x: (x.position, x.timestamp, x.node_id))
def read(self) -> str:
return "".join([c.char for c in self.elements if not c.deleted])
def merge(self, incoming_nodes: List[CharNode]) -> None:
local_map = {node.id: node for node in self.elements}
for incoming in incoming_nodes:
if incoming.id in local_map:
existing = local_map[incoming.id]
# Conflict resolution: Last-Write-Wins based on timestamp
if incoming.timestamp > existing.timestamp:
existing.deleted = incoming.deleted
existing.timestamp = incoming.timestamp
else:
self.elements.append(incoming)
self._sort_elements()
3. Simulating Concurrent Editing & Partition Merge
Here is a test showing two offline nodes editing simultaneously and converging cleanly once reconnected:
if __name__ == "__main__":
# Initialize document replicas for Alice and Bob
doc_alice = CollaborativeDocument(node_id="alice")
doc_bob = CollaborativeDocument(node_id="bob")
# Alice types 'CAT'
doc_alice.insert(0, 'C')
doc_alice.insert(1, 'A')
doc_alice.insert(2, 'T')
# Initial sync from Alice to Bob
doc_bob.merge(doc_alice.elements)
print(f"Initial Bob View: {doc_bob.read()}") # Output: CAT
# Network partition: Alice and Bob both go offline
# Alice appends 'S' at index 3 -> 'CATS'
doc_alice.insert(3, 'S')
# Bob concurrently inserts 'WILD ' at index 0 -> 'WILD CAT'
for idx, letter in enumerate("WILD "):
doc_bob.insert(idx, letter)
print(f"Alice Offline: {doc_alice.read()}") # CATS
print(f"Bob Offline: {doc_bob.read()}") # WILD CAT
# Network heals: Full two-way merge
doc_alice.merge(doc_bob.elements)
doc_bob.merge(doc_alice.elements)
print("--- After Sync ---")
print(f"Alice Final: {doc_alice.read()}")
print(f"Bob Final: {doc_bob.read()}")
assert doc_alice.read() == doc_bob.read()
print("Deterministic convergence verified successfully.")
Conclusion: 6-Month Production Takeaways
Deploying CRDTs to production revealed trade-offs rarely covered in introductory guides:
- Tombstone Accumulation: Deletions retain character metadata instead of freeing memory. In an active 20-page document with 10,000 edits, tombstones ballooned our payload from 60 KB to over 2.4 MB. Over 80% of the wire payload was dead markers. We solved this with garbage-collection checkpoints triggered whenever all peer vector clocks passed a verified threshold.
- Floating Point Precision Drift: Naive float division like
(pos1 + pos2) / 2.0runs out of precision quickly. After 53 consecutive insertions between adjacent characters, standard 64-bit floats exhaust their mantissa. Production sequence engines require arbitrary-precision index trees (such as Logoot or LSEQ) rather than IEEE 754 floats. - Payload Overheads: Transmitting full state graphs over mobile connections drains battery and bandwidth. Switching from full-state snapshots to delta-based CRDT updates slashed our average WebSocket message size from 1.8 MB down to 14 KB—a 92% bandwidth drop.
CRDTs eliminate complex lock orchestration and resolve race conditions at the data model level. When building collaborative canvases, offline-first mobile apps, or distributed registries, semilattices provide a solid foundation for reliable sync.

