CRDTs for Web Developers: A Practical Guide Without the Academic Jargon
CRDTs for Web Developers: A Practical Guide Without the Academic Jargon
You have probably used CRDTs without knowing it. Every time you edit a Google Doc and watch someone else's cursor move across the page, you are watching Conflict-free Replicated Data Types in action. The document stays consistent even though two people type in the same paragraph at the same time, on different devices, with different network latencies.
The academic literature around CRDTs is dense. Papers filled with formal proofs, semilattices, and vector clocks. But as a web developer building a collaborative app, you do not need to understand the math. You need to understand what CRDTs do, when to use them, and how to implement them in code you actually ship.
This guide cuts through the theory and shows you how CRDTs work with concrete JavaScript and PHP examples you can adapt today.
The Problem CRDTs Solve
Imagine a shared todo list. Two users, Alice and Bob, are both offline. Alice changes the task title from "Buy groceries" to "Buy organic groceries." Bob changes it from "Buy groceries" to "Buy groceries for dinner."
When both come back online, the server receives two conflicting updates to the same field. What should the final value be?
Option one: last-write-wins. Whoever's update arrives last gets saved. But "last" depends on network latency, not intention. Bob's update might arrive last simply because his connection was slower, even though Alice edited first.
Option two: manual resolution. Show both versions to the user and ask them to pick. This works for rare conflicts but becomes exhausting in collaborative apps where conflicts happen constantly.
Option three: CRDTs. Structure your data so that conflicts are mathematically impossible. Both edits merge automatically in a way that is deterministic — everyone arrives at the same result regardless of the order operations arrive.
CRDTs are the third option.
The Core Idea: Operations That Commute
A CRDT is a data structure where operations can be applied in any order and still produce the same result. In math terms, the operations commute.
Think of a counter. If Alice increments by 1 and Bob increments by 2, the result is 3 regardless of who goes first. Addition commutes. A counter is the simplest CRDT.
Most data structures are not that simple. Setting a value does not commute: set(x, "A") followed by set(x, "B") gives "B", but reverse the order and you get "A". Sets, maps, and text sequences all have non-commutative operations.
CRDTs solve this by transforming non-commutative operations into commutative ones through clever encoding. There are two families of CRDTs that take different approaches to this transformation.
CvRDTs: State-Based CRDTs
Convergent Replicated Data Types work by merging entire states. Each replica holds a full copy of the data structure. When replicas sync, they merge their states using a deterministic merge function that is commutative, associative, and idempotent.
The merge function is the only thing you need to get right. As long as merging state A with state B always produces the same result as merging B with A, and merging a state with itself produces the same state, you have a valid CvRDT.
Example: A Grow-Only Set (G-Set)
The simplest CvRDT is a grow-only set. Elements can be added but never removed. The merge operation is set union.
class GSet {
constructor() {
this.elements = new Set();
}
add(element) {
this.elements.add(element);
}
merge(other) {
const result = new GSet();
for (const elem of this.elements) {
result.add(elem);
}
for (const elem of other.elements) {
result.add(elem);
}
return result;
}
}
Example: A Last-Writer-Wins Register (LWW-Register)
A register holds a single value. When conflicts occur, the most recently timestamped value wins.
class LWWRegister {
constructor(value = null, timestamp = 0, nodeId = '') {
this.value = value;
this.timestamp = timestamp;
this.nodeId = nodeId;
}
set(value, timestamp, nodeId) {
if (timestamp > this.timestamp ||
(timestamp === this.timestamp && nodeId > this.nodeId)) {
this.value = value;
this.timestamp = timestamp;
this.nodeId = nodeId;
}
}
merge(other) {
if (other.timestamp > this.timestamp ||
(other.timestamp === this.timestamp && other.nodeId > this.nodeId)) {
this.value = other.value;
this.timestamp = other.timestamp;
this.nodeId = other.nodeId;
}
}
}
CmRDTs: Operation-Based CRDTs
Commutative Replicated Data Types work by broadcasting operations instead of states. Each operation carries enough context to be applied correctly at any replica, regardless of order.
Example: A G-Counter (Grow-Only Counter)
A G-Counter tracks increments per node. To get the total value, sum all node counters.
class GCounter {
constructor() {
this.counts = new Map();
}
increment(nodeId, amount = 1) {
const current = this.counts.get(nodeId) || 0;
this.counts.set(nodeId, current + amount);
}
value() {
let total = 0;
for (const count of this.counts.values()) {
total += count;
}
return total;
}
merge(other) {
for (const [nodeId, count] of other.counts.entries()) {
const current = this.counts.get(nodeId) || 0;
this.counts.set(nodeId, Math.max(current, count));
}
}
}
When to Use Which Type?
Use CvRDTs when:
- Your data is small enough to transmit full states
- You want simple merge logic (just compare and take max)
- Network partitions are common (state-based handles this naturally)
Use CmRDTs when:
- Operations are much smaller than states (like text edits)
- You need real-time updates with low latency
- You can guarantee causal delivery (operations arrive in partial order)
Implementing CRDTs in Production
For most web apps, you should use an existing library rather than rolling your own. Yjs is the most mature CRDT library for JavaScript, supporting rich text, JSON documents, and custom types.
import * as Y from 'yjs';
// Create a shared document
const doc = new Y.Doc();
const text = doc.getText('content');
// Local user types
text.insert(0, 'Hello ');
// Remote user's changes arrive
text.insert(6, 'world');
// Result: "Hello world" - order doesn't matter
console.log(text.toString());
What CRDTs Cannot Do
CRDTs are not magic. They solve specific problems:
✓ Concurrent edits to independent data — merging works naturally ✓ Text editing — character-level CRDTs preserve ordering ✓ Counters, sets, maps — well-defined merge semantics
✗ Domain-specific conflicts — "booked flight at 2pm" vs "booked flight at 3pm" still needs business logic ✗ External state — if CRDT state depends on a database row, that dependency breaks commutativity ✗ Security and authorization — CRDTs don't solve permission conflicts
CRDTs handle data consistency. They do not handle business logic consistency.
From Theory to Your App
Start with the problem you're solving:
- What are you syncing? Text? A counter? A set of items?
- How do conflicts manifest? Two people editing the same field? Adding the same item twice?
- What's the desired outcome? Merge both? Last write wins? Ask the user?
For simple cases like counters and sets, a basic CRDT implementation is straightforward. For text editing, use Yjs or Automerge. For custom data structures, pick the CRDT type that matches your merge semantics.
The math behind CRDTs is complex. The implementation, for most web apps, doesn't have to be. Start with the problem, pick the right tool, and ship.