Conflict-Resolution CRDTs
Operational transform solves concurrent edits by transforming operations against each other, but it requires a server to order them. What happens when two clients edit the same data while offline and reconnect later? There is no server to order the operations retroactively, so OT cannot help. CRDTs take a different approach: instead of transforming operations, they design data structures whose merge function is mathematically guaranteed to produce the same result regardless of what order the merges happen in. No coordination, no central arbiter, just two replicas that can always be combined correctly.
This guide follows operational transform basics and provides the alternative architecture that powers offline-first and peer-to-peer collaborative tools.
What you'll learn
The three CRDT properties
A CRDT merge function must satisfy three mathematical properties. Together they guarantee that any two replicas can always be reconciled.
Commutative, associative, idempotent
Commutativity means merge(A, B) = merge(B, A): order does not matter. Associativity means merge(merge(A, B), C) = merge(A, merge(B, C)): grouping does not matter. Idempotency means merge(A, A) = A: merging the same state twice produces no change, so receiving a duplicate update is harmless. These three properties together mean that any number of replicas can exchange their states in any order, at any time, and always converge to the same result. No version vectors, no conflict detection, no server arbitration required.
Grow-only counter (G-Counter)
The simplest CRDT is also the most illustrative. A grow-only counter can never decrement, and its merge is trivial.
Per-replica slots and max merge
// G-Counter: each replica owns one slot in a vector
var gCounter = {
// nodeId -> count
state: { 'node-A': 0, 'node-B': 0, 'node-C': 0 },
increment: function (nodeId) {
this.state[nodeId] = (this.state[nodeId] || 0) + 1;
},
value: function () {
return Object.values(this.state).reduce(function (s, n) { return s + n; }, 0);
},
merge: function (other) {
var result = {};
var allKeys = new Set([...Object.keys(this.state), ...Object.keys(other.state)]);
allKeys.forEach(function (k) {
result[k] = Math.max(this.state[k] || 0, other.state[k] || 0);
}.bind(this));
return { state: result };
}
};
Each replica increments only its own slot. The total is the sum of all slots. Merging two replicas takes the maximum of each slot. Because slots never shrink, the maximum is always safe: it represents the highest count that any replica has seen for that node. Replica A with slot node-A: 5 merged with Replica B with slot node-A: 3 produces node-A: 5, the correct result regardless of which merge happened first.
Last-write-wins register
A register holds a single mutable value. The LWW register resolves concurrent writes by timestamp rather than by transformation.
Timestamp-based merge for a single field
var lwwRegister = {
value: null,
timestamp: 0,
nodeId: 'node-A',
set: function (value) {
this.value = value;
this.timestamp = Date.now();
},
merge: function (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;
}
}
};
The merge rule is always deterministic: the higher timestamp wins; equal timestamps break ties by node ID. Because every node uses the same rule, all replicas converge to the same winner. The trade-off is that one concurrent write is silently discarded, which is the right behaviour for a field like a document title but wrong for a shopping cart quantity. LWW is the simplest CRDT for a single mutable field and the building block for CRDT maps.
OR-Set for collaborative lists
Adding and removing items from a shared list is harder than it looks. The OR-Set solves the classic add-remove conflict where two replicas disagree about whether an element exists.
Unique tags survive concurrent remove-add
var orSet = {
// element -> Set of unique add-tags still present
entries: new Map(),
add: function (element) {
var tag = element + '-' + Date.now() + '-' + Math.random();
if (!this.entries.has(element)) this.entries.set(element, new Set());
this.entries.get(element).add(tag);
},
remove: function (element) {
// Remove all tags observed so far; new concurrent adds keep their own tags
this.entries.delete(element);
},
has: function (element) {
var tags = this.entries.get(element);
return tags && tags.size > 0;
},
merge: function (other) {
other.entries.forEach(function (otherTags, element) {
var localTags = this.entries.get(element) || new Set();
otherTags.forEach(function (t) { localTags.add(t); });
this.entries.set(element, localTags);
}.bind(this));
}
};
Each add operation generates a unique tag. A remove operation deletes all tags for an element that were known at remove time. If a concurrent add happens on another replica with a new tag, that tag survives the merge because the remove never knew about it. This is the "add wins" semantics: if two replicas concurrently add and remove the same item, the add wins after merge. OR-Sets are the CRDT behind shared todo lists, presence sets, and tag systems.
RGA for collaborative text
Replicated Growable Array is the CRDT that underpins text editing in libraries like Yjs. It assigns every character a unique identifier so insertions can be placed precisely relative to their neighbours regardless of concurrent changes elsewhere.
Unique character IDs and tombstones
// Simplified RGA node
// Each character has: id (unique), value, after (id of predecessor), deleted flag
var rga = {
nodes: [{ id: 'root', value: null, after: null, deleted: false }],
insert: function (afterId, value, nodeId) {
var id = nodeId + '-' + Date.now() + '-' + Math.random();
this.nodes.push({ id: id, value: value, after: afterId, deleted: false });
return id;
},
delete: function (id) {
var node = this.nodes.find(function (n) { return n.id === id; });
if (node) node.deleted = true; // tombstone, never truly removed
},
text: function () {
// Linearise nodes then filter tombstones
return this.linearise().filter(function (n) { return !n.deleted && n.value; })
.map(function (n) { return n.value; }).join('');
}
};
Each character carries a unique ID and a pointer to the character it was inserted after. Deletions are tombstones: the node stays in the list with a deleted flag rather than being removed, so other replicas that have not yet received the deletion can still find the node and correctly position their own concurrent insertions relative to it. The text is derived by linearising nodes in insertion order and filtering tombstones. Yjs and Automerge implement production-quality versions of this approach with efficient binary encoding and garbage collection for old tombstones.
Choosing CRDTs vs OT
Both CRDTs and OT solve concurrent editing. The choice depends on your system's topology and the shape of your data.
A practical decision framework
Choose CRDTs when peers need to edit offline and sync later without a server in the loop, when the network can partition and recovery must be automatic, or when your data is structured rather than linear, maps, sets, trees, and counters are all natural CRDTs. Choose OT when the editing model is linear text in a server-coordinated architecture and simplicity matters more than offline capability. In practice, the choice is often made by the library: Yjs and Automerge are battle-tested CRDT implementations that expose a simple document API and handle the internal complexity. ShareDB and ot.js serve the same role for OT. Both integrate with the same transport layer, WebSocket and SSE, so the frontend event handling is identical; only the data structure underneath differs.
Frequently Asked Questions
What is a CRDT?
A CRDT, or conflict-free replicated data type, is a data structure whose merge function is commutative, associative, and idempotent. These mathematical properties guarantee that any two replicas can be merged in any order and arrive at the same result, without coordination or conflict resolution logic.
How does a grow-only counter work as a CRDT?
A grow-only counter assigns each replica its own slot in a vector. Each replica only increments its own slot. The total count is the sum of all slots. Merging two replicas takes the maximum of each slot. Because slots only grow, the merge is always correct regardless of order.
What is a last-write-wins register?
A last-write-wins register stores a value together with a timestamp or logical clock. On merge, the replica with the higher timestamp wins. Writes from different replicas at the same instant break ties by node ID. This is the simplest CRDT for a single mutable field.
When should I use a CRDT instead of OT?
Prefer CRDTs when you need peer-to-peer collaboration without a central server, offline editing with later sync, or conflict-free merging for structured data like maps, sets, and trees. OT is simpler to reason about for linear text in a server-coordinated system. Yjs and Automerge are production-ready CRDT libraries that handle the complexity for you.
Read next: the Interactive Web hub, or revisit operational transform basics to compare both approaches side by side.
Want a data model that merges without conflicts?
The Backbone guide shows how models integrate with CRDT-backed data sources through clean event bindings.
Explore the Backbone Guide →