Real-time collaboration is the defining capability of modern productivity software. Applications like Google Docs, Figma, Notion, and Linear allow multiple geographically distributed users to type, edit, draw, and reorganize content simultaneously over public networks—without locking the document and without overwriting each other’s work.
Solving concurrent document editing is one of the classic problems of distributed systems engineering. When two users type concurrently without coordination, traditional string indices diverge, text scrambles, and data is lost.
This lesson explores the two primary architectures for real-time collaboration: Operational Transformation (OT) and Conflict-Free Replicated Data Types (CRDTs).
1. Problem and Motivation
Consider two users, Alice and Bob, collaborating on a shared document that currently contains the word:
Alice and Bob perform concurrent edits before their devices exchange network packets:
- Alice inserts
'O'at index (intending to write"COAT"): - Bob inserts
'S'at index (intending to write"CATS"):
The Naive Replication Failure
- Alice applies locally . She receives Bob’s . Alice applies it literally at index 3 (incorrect).
- Bob applies locally . He receives Alice’s . Bob applies it literally at index 1 .
Alice’s document is "COAST", while Bob’s document is "COATS". The replicas have diverged permanently!
To ensure all replicas converge deterministically, systems must use either Operational Transformation or Conflict-Free Replicated Data Types.
2. Mental Model & Architectural Taxonomy
1. Initial Doc State: "CAT" (Length = 3) & Client A (Alice)
Establishes the ingress and structural boundary across Initial Doc State: "CAT" (Length = 3) & Client A (Alice): Insert 'O' at index 1; Local: "COAT"; op_A = ins(1, 'O').
2. Central Server: Transformation Function T(op_B, op_A) & Advantages: Minimal memory overhead (index strings)
Coordinates core data-plane execution and state transitions through Central Server: Transformation Function T(op_B, op_A) & Advantages: Minimal memory overhead (index strings): Because op_A inserted before op_B (1 < 3), op_B index MUST shift by +1 to compensate! Transformed op_B' = ins(4, 'S'); Result at all clients: "COATS" (Converged!); Critical Weakness: Central Single Point of Failure.
3. Mathematical Semilattice Properties (Merge ⊔) & Advantages: Flawless Offline Sync & Peer-to-Peer
Enforces durability, state convergence, and output verification across Mathematical Semilattice Properties (Merge ⊔) & Advantages: Flawless Offline Sync & Peer-to-Peer: Any two replicas merge deterministically via Least Upper Bound; Commutative: A ⊔ B = B ⊔ A (Arrival order doesn't matter); Associative: (A ⊔ B) ⊔ C = A ⊔ (B ⊔ C) (Batch grouping doesn't matter).
Operational Transformation (OT) vs. CRDTs
| Feature / Dimension | Operational Transformation (OT) | Conflict-Free Replicated Data Types (CRDT) |
|---|---|---|
| Topology | Centralized Client-Server (Mandatory central sequencer) | Decentralized / Peer-to-Peer (No central server required) |
| Operation Format | Positional index offsets: | Immutable Lamport Identifiers: |
| Convergence Mechanism | Server rewrites operation indices via Transformation Matrix | Mathematical join-semilattice (); commutative character ordering |
| Offline Sync Support | Poor. Long offline edits cause massive transformation rebase conflicts | Native. Nodes can edit offline for months and merge cleanly |
| Memory Overhead | Zero. Documents are stored as compact plain strings | Higher. Requires tombstones and metadata per character/item |
| Production Examples | Google Docs, Apache Wave, ShareDB, Etherpad | Yjs, Automerge, Figma, Apple Notes, Linear |
3. Deep Dive: Operational Transformation (OT)
Operational Transformation was introduced in 1989 by Ellis and Gibbs. The core insight of OT is: Rather than altering the document directly, operations are mathematically transformed before execution to account for concurrent operations.
The Transformation Function
Given two concurrent operations and :
- produces , representing the intention of after has already executed.
- produces , representing the intention of after has executed.
For our "CAT" example:
- Alice:
- Bob:
Because Alice inserted at index (which is ), Bob’s insertion target shifted right by one position!
When Alice applies :
Both Alice and Bob converge to "COATS".
Why OT Requires a Central Sequencer
In a multi-user environment ( users), operations can arrive in arbitrary permutations. Proving the TP2 property (Transformation Property 2: transforming over two concurrent operations in either order yields the same result) across decentralized peers proved mathematically fraught and prone to edge-case desynchronization bugs. Google Docs solved this by channeling all operations through a central server, which assigns a strict monotonic revision number () and forces clients to rebase locally.
4. Deep Dive: Conflict-Free Replicated Data Types (CRDTs)
CRDTs (formalized by Marc Shapiro et al. in 2011) take a radically different approach: Avoid index rewrites entirely by assigning every element an immutable, globally unique identifier.
4.1 Replicated Growable Array (RGA)
In an RGA sequence CRDT:
- Every character is represented by a node containing:
- Characters are ordered in a causal tree. If two users insert after the same character concurrently, ties are broken deterministically using Lamport timestamps and unique client IDs:
4.2 Mathematical Semilattice Properties
A state-based CRDT (CvRDT) forms a join-semilattice equipped with a partial order and a merge operator (least upper bound) satisfying three axioms:
- Commutativity:
(Packets arriving out of order produce the same result). - Associativity:
(Network messages batched into packets produce the same result). - Idempotency:
(Duplicate packet delivery causes zero side effects).
5. Production TypeScript Simulation: Replicated Growable Array (RGA) CRDT
Below is an executable sequence CRDT supporting concurrent character insertions, Lamport clock ordering, and tombstone deletions:
export interface CharNode {
id: string; // e.g. "clientA:1"
char: string;
clock: number;
clientId: string;
deleted: boolean;
next: CharNode | null;
}
export class RgaDocument {
public head: CharNode = {
id: "START",
char: "",
clock: 0,
clientId: "",
deleted: true,
next: null,
};
private clock = 0;
constructor(public readonly clientId: string) {}
public insertAfter(targetId: string, char: string): CharNode {
this.clock++;
const newNode: CharNode = {
id: `${this.clientId}:${this.clock}`,
char,
clock: this.clock,
clientId: this.clientId,
deleted: false,
next: null,
};
this.integrate(newNode, targetId);
return newNode;
}
public delete(id: string): void {
let curr: CharNode | null = this.head;
while (curr) {
if (curr.id === id) {
curr.deleted = true; // Mark tombstone
return;
}
curr = curr.next;
}
}
/**
* Deterministically merges an incoming remote node into the local RGA sequence
*/
public integrate(newNode: CharNode, targetId: string): void {
// 1. Locate the parent node
let prev: CharNode | null = this.head;
while (prev && prev.id !== targetId) {
prev = prev.next;
}
if (!prev) {
prev = this.head; // Fallback to start
}
// Advance clock to maintain causality
this.clock = Math.max(this.clock, newNode.clock);
// 2. Scan past any concurrent sibling nodes with higher Lamport priority
let curr = prev.next;
while (curr) {
if (curr.clock > newNode.clock) {
prev = curr;
curr = curr.next;
} else if (curr.clock === newNode.clock && curr.clientId > newNode.clientId) {
prev = curr;
curr = curr.next;
} else {
break; // Found correct deterministic insertion slot
}
}
newNode.next = curr;
prev.next = newNode;
}
public toString(): string {
let result = "";
let curr = this.head.next;
while (curr) {
if (!curr.deleted) {
result += curr.char;
}
curr = curr.next;
}
return result;
}
}
function runDemo(): void {
console.log('=== Collaborative Real-Time Text Editing: RGA CRDT ===');
const docA = new RgaDocument('client-A');
const docB = new RgaDocument('client-B');
const n1 = docA.insertAfter('START', 'H');
const n2 = docA.insertAfter(n1.id, 'i');
console.log('Client A doc: ' + docA.toString());
docB.integrate({ ...n1, next: null }, 'START');
docB.integrate({ ...n2, next: null }, n1.id);
console.log('Client B doc: ' + docB.toString());
const nA = docA.insertAfter(n2.id, '!');
const nB = docB.insertAfter(n2.id, '?');
docB.integrate({ ...nA, next: null }, n2.id);
docA.integrate({ ...nB, next: null }, n2.id);
console.log('Doc A converged: ' + docA.toString());
console.log('Doc B converged: ' + docB.toString());
console.log('Bitwise convergence: ' + (docA.toString() === docB.toString()));
}
runDemo();
6. Open-Source Implementation Analysis
A. Yjs (High-Performance Modern CRDT)
Yjs is written by Kevin Jahns and powers collaborative editing in Jupyter, GitBook, and affine.pro:
- Struct Store Optimization: Traditional academic CRDTs store every character as an individual heap object (incurring a RAM expansion). Yjs consolidates sequential characters typed by the same user into contiguous string slices inside a flat binary array (
Itemstructs), cutting RAM overhead by . - Binary Sync Protocol: Encodes document updates into compact byte buffers using variable-length integer encoding (lib0), streaming updates over WebSockets or WebRTC.
B. Figma: Scene Graph CRDT
Figma does not model documents as text characters. A design file is an acyclic scene graph of trees:
- Every canvas frame, rectangle, and vector has a globally unique 64-bit ID.
- Properties (e.g.,
width,fill_color,x_pos) are independent LWW (Last-Write-Wins) registers using Lamport clocks. - Z-index layer order uses Fractional Indexing (e.g., placing an item between layer
0.5and0.75assigns0.625), enabling reordering without modifying neighboring nodes.
7. Failure Modes & Operational War Stories
War Story: The Infinite Tombstone Memory Leak
- Incident: A collaborative text platform using an early CRDT library crashed user browser tabs with
JavaScript heap out of memoryafter two weeks of heavy editing. - Root Cause: In sequence CRDTs, deleted characters are marked with tombstones (
deleted: true) so concurrent operations can still resolve their relative positions. In this platform, an automated linter repeatedly re-formatted lines of code every 5 minutes. Within 14 days, the document accumulated invisible tombstone nodes in memory! - Mitigation:
- Implemented Garbage Collection of Tombstones: When all active collaborating clients have acknowledged seeing a deletion (proven via state vectors), tombstones that have no dependent child nodes are pruned.
- Consolidated runs of adjacent deleted characters into single summary ranges.
8. Operational Decision Guide
| System Use Case | Recommended Architecture | Justification |
|---|---|---|
| Rich Text Docs with Centralized Backend | Operational Transformation (ShareDB) | Minimal memory footprint; mature server-side authorization. |
| Local-First App / Offline-First / P2P | CRDT (Yjs / Automerge) | Flawless offline merges; zero reliance on a central server. |
| Design Canvas / Whiteboard / UI Builder | Design-Object CRDT with Fractional Indexing | Property-level LWW; minimal conflict rate on visual objects. |
9. Hands-on Operational Drills
- Simulate Concurrent RGA Merging: Instantiate two
RgaDocumentinstances: Doc A and Doc B. Starting from"CAT":- On Doc A, insert
'O'after'C'. - On Doc B, insert
'S'after'T'. - Exchange the generated nodes between Doc A and Doc B via
integrate(). Verify that both documents converge to"COATS".
- On Doc A, insert
- Verify Tombstone Deletion: Delete character
'O'on Doc A. Verify thattoString()outputs"CATS", while the underlying node remains intact in memory to preserve causal linked ordering.
View Reference Architecture & Worked Solutions (APP-A10-001)
1. Reference Solution — Drill 1: Simulate Concurrent RGA Merging ("CAT" "COATS")
- Step-by-Step RGA Causal Trace:
- Initial Synchronized State (
"CAT"):docAcreatescNode = docA.insertAfter('START', 'C')(id: 'client-A:1'),aNode = docA.insertAfter('client-A:1', 'A')(id: 'client-A:2'), andtNode = docA.insertAfter('client-A:2', 'T')(id: 'client-A:3').docBintegrates all three nodes in order, advancingdocB.clockto3. BothdocA.toString()anddocB.toString()equal"CAT".
- Concurrent Offline Edits:
- On
docA:oNode = docA.insertAfter('client-A:1', 'O')produces{ id: 'client-A:4', char: 'O', clock: 4, parent: 'client-A:1' }. LocallydocA.toString() === "COAT". - On
docB:sNode = docB.insertAfter('client-A:3', 'S')produces{ id: 'client-B:4', char: 'S', clock: 4, parent: 'client-A:3' }. LocallydocB.toString() === "CATS".
- On
- Cross-Replica Integration:
docA.integrate({ ...sNode, next: null }, 'client-A:3')locates parent'client-A:3'('T') and links'S'immediately after'T'.docB.integrate({ ...oNode, next: null }, 'client-A:1')locates parent'client-A:1'('C') and links'O'immediately after'C'(ahead of'client-A:2''A'becauseoNode.clock (4) > aNode.clock (2)).- Both linked lists now traverse
START -> C(A:1) -> O(A:4) -> A(A:2) -> T(A:3) -> S(B:4)and evaluate to"COATS"with zero index arithmetic!
- Initial Synchronized State (
2. Reference Solution — Drill 2: Verify Tombstone Deletion & Causal Anchor Preservation
- Execution & Invariant Verification:
- Calling
docA.delete('client-A:4')locates node'client-A:4'('O') and flipscurr.deleted = truewithout mutatingprev.nextorcurr.next. - Calling
docA.toString()skips any node withdeleted === true, returning"CATS", while inspecting the internal list confirms'client-A:4'still bridges'client-A:1'('C') and'client-A:2'('A'). - Why This Matters: If a delayed offline operation from
docBarrives later withdocB.integrate(rNode, 'client-A:4')(e.g., inserting'R'after'O'),docAstill finds'client-A:4'in the chain and deterministically places'R'between the tombstoned'O'and'A'("CRATS"), whereas physical node removal would have orphanedrNodeand caused replica divergence.
- Calling
10. Trade-offs & Collaborative Engine Alternatives
| Collaboration Engine | Topology Requirement | Memory Overhead | Offline Divergence Handling | Production Adoption |
|---|---|---|---|---|
| Operational Transformation (OT) | Centralized sequencer | Low (No metadata tombstones) | Complex (Requires rebasing operations) | Google Docs, Apache Wave |
| Sequence CRDT (RGA / YATA) | Peer-to-peer / Mesh | Moderate (Requires character metadata) | Mathematical convergence guarantee | Yjs, Automerge |
| State-Based CRDT (CvRDT) | Gossip / Broadcast | High (Sends full state on each sync) | Idempotent lattice join | DynamoDB, Riak KV |
| Scene Graph LWW-CRDT | Client-Server / WebSocket | Low to Moderate (Property-level registers) | Fractional indexing reordering | Figma Canvas Engine |
11. Authoritative References & Foundational Research
- Marc Shapiro et al. (2011), “Conflict-Free Replicated Data Types”, SSS ‘11 — doi.org/10.1007/978-3-642-24550-3_29
- Martin Kleppmann and Alastair R. Beresford (2017), “A Conflict-Free Replicated JSON Datatype”, IEEE TPDS — doi.org/10.1109/TPDS.2017.2697382
- Kevin Jahns (2020), “Yjs: Shared Data Types for Building Collaborative Software Architecture” — github.com/yjs/yjs
- Version Context: Reference implementations align with Yjs 13.6+ and Automerge 2.0+.