CRDTs vs Operational Transformation: A Practical Guide to Real-Time Collaboration

Written by saumyatyagi | Published 2025/12/24
Tech Story Tags: distributed-systems | crdt | real-time-collaboration | collabrative-editing | software-engineering | typescript | concurrent-edits | conflict-free-replicated-data

TLDROperational Transformation (OT) lets multiple people type in the same document without creating chaos. OT was invented at Xerox PARC in 1989 and powers Google Docs, Google Wave (RIP), and many collaborative editors. When you receive a remote operation, you transform it against operations that have been applied locally.via the TL;DR App

Ever wondered how Google Docs lets multiple people type in the same document without creating chaos? Or how Figma keeps everyone's designs in sync? Behind these seamless experiences lie two powerful techniques: Operational Transformation (OT) and Conflict-free Replicated Data Types (CRDTs).

In this article, I'll break down both approaches, show you when to use each, and walk through simple implementations you can actually build.

The Problem: Concurrent Edits Are Hard

Imagine Alice and Bob are editing a document containing "Hello". Simultaneously:

  • Alice inserts " World" at position 5
  • Bob deletes "H" at position 0

If we apply these operations naively, we get conflicts. Alice expects "Hello World" while Bob expects "ello". When we try to merge, chaos ensues.

This is the core challenge of collaborative editing: how do we ensure everyone ends up with the same result regardless of the order they receive updates?

Operational Transformation: The Pioneer

OT was invented at Xerox PARC in 1989 and powers Google Docs, Google Wave (RIP), and many collaborative editors.

The Core Idea

OT transforms operations based on what has happened concurrently. When you receive a remote operation, you don't apply it directly—you transform it against operations that have already been applied locally.

How It Works

Let's revisit our example. The document starts as "Hello" (positions 0-4).

Alice's operation: Insert(" World", 5) Bob's operation: Delete(0, 1) (delete 1 character at position 0)

When Bob receives Alice's insert, he needs to transform it. Since Bob deleted a character before position 5, Alice's insert position needs to shift:

Transform(Insert(" World", 5), Delete(0, 1)) = Insert(" World", 4)

Similarly, when Alice receives Bob's delete, no transformation is needed since position 0 comes before her insert at position 5.

A Simple OT Implementation

Here's a minimal OT system for text editing in TypeScript:

type Operation = 
  | { type: 'insert'; position: number; text: string }
  | { type: 'delete'; position: number; length: number };

function transformOperation(op: Operation, against: Operation): Operation {
  if (op.type === 'insert' && against.type === 'insert') {
    // If the other insert comes before ours, shift our position
    if (against.position <= op.position) {
      return { ...op, position: op.position + against.text.length };
    }
    return op;
  }
  
  if (op.type === 'insert' && against.type === 'delete') {
    // If deletion is before our insert, shift left
    if (against.position < op.position) {
      return { 
        ...op, 
        position: Math.max(against.position, op.position - against.length) 
      };
    }
    return op;
  }
  
  if (op.type === 'delete' && against.type === 'insert') {
    // If insert is before our delete, shift right
    if (against.position <= op.position) {
      return { ...op, position: op.position + against.text.length };
    }
    return op;
  }
  
  if (op.type === 'delete' && against.type === 'delete') {
    // Handle overlapping deletes
    if (against.position >= op.position + op.length) {
      return op; // No overlap, delete comes before
    }
    if (against.position + against.length <= op.position) {
      // Other delete is entirely before ours
      return { ...op, position: op.position - against.length };
    }
    // Overlapping deletes - complex case
    const overlapStart = Math.max(op.position, against.position);
    const overlapEnd = Math.min(
      op.position + op.length, 
      against.position + against.length
    );
    const overlapLength = overlapEnd - overlapStart;
    
    return {
      ...op,
      position: Math.min(op.position, against.position),
      length: op.length - overlapLength
    };
  }
  
  return op;
}

The Catch: OT Is Complex

That simple implementation above? It's incomplete. Real OT systems need to handle:

  • Transformation puzzles: The order of transformations matters, and getting it wrong leads to divergence
  • Server authority: Most OT systems require a central server to determine canonical ordering
  • Operational complexity: Each operation type needs transformation rules against every other type (O(n²) combinations)

Google's OT implementation for Docs reportedly has edge cases that took years to iron out.

CRDTs: The Decentralized Alternative

CRDTs emerged from distributed systems research in the 2000s. The key insight: design your data structures so that concurrent operations always converge, without requiring transformation.

The Core Idea

CRDTs achieve this through mathematical properties. A CRDT is either:

  • State-based (CvRDT): Replicas merge states using a function that is commutative, associative, and idempotent
  • Operation-based (CmRDT): Operations are designed to be commutative—order doesn't matter

A Simple CRDT: G-Counter

Let's start with the simplest CRDT—a counter that can only grow:

class GCounter {
  private counts: Map<string, number> = new Map();
  
  constructor(private nodeId: string) {}
  
  increment(): void {
    const current = this.counts.get(this.nodeId) || 0;
    this.counts.set(this.nodeId, current + 1);
  }
  
  value(): number {
    let total = 0;
    for (const count of this.counts.values()) {
      total += count;
    }
    return total;
  }
  
  merge(other: GCounter): void {
    for (const [nodeId, count] of other.counts) {
      const current = this.counts.get(nodeId) || 0;
      this.counts.set(nodeId, Math.max(current, count));
    }
  }
  
  getState(): Map<string, number> {
    return new Map(this.counts);
  }
}

Each node tracks its own count. Merging takes the maximum per node. No matter what order you merge, you get the same result.

Text CRDTs: The Real Challenge

For collaborative text editing, we need something more sophisticated. The two main approaches are:

1. Sequence CRDTs (like RGA, WOOT)

Assign each character a unique, ordered ID that preserves intention regardless of when operations arrive.

2. Tree-based CRDTs (like YATA, used in Yjs)

Model the document as a tree where position is determined by relationships, not indices.

Here's a simplified sequence CRDT:

interface Char {
  id: { nodeId: string; seq: number };
  value: string;
  deleted: boolean;
  // Position determined by ID ordering, not array index
}

class TextCRDT {
  private chars: Char[] = [];
  private seq = 0;
  
  constructor(private nodeId: string) {}
  
  insert(index: number, value: string): Char {
    const id = { nodeId: this.nodeId, seq: this.seq++ };
    const char: Char = { id, value, deleted: false };
    
    // Find actual position accounting for tombstones
    let actualIndex = 0;
    let visibleCount = 0;
    while (visibleCount < index && actualIndex < this.chars.length) {
      if (!this.chars[actualIndex].deleted) {
        visibleCount++;
      }
      actualIndex++;
    }
    
    this.chars.splice(actualIndex, 0, char);
    return char;
  }
  
  delete(index: number): void {
    let visibleCount = 0;
    for (const char of this.chars) {
      if (!char.deleted) {
        if (visibleCount === index) {
          char.deleted = true; // Tombstone, don't remove
          return;
        }
        visibleCount++;
      }
    }
  }
  
  applyRemote(char: Char): void {
    // Find correct position based on ID ordering
    let insertIndex = 0;
    for (let i = 0; i < this.chars.length; i++) {
      if (this.compareIds(char.id, this.chars[i].id) < 0) {
        break;
      }
      insertIndex = i + 1;
    }
    this.chars.splice(insertIndex, 0, char);
  }
  
  private compareIds(a: Char['id'], b: Char['id']): number {
    if (a.seq !== b.seq) return a.seq - b.seq;
    return a.nodeId.localeCompare(b.nodeId);
  }
  
  getText(): string {
    return this.chars
      .filter(c => !c.deleted)
      .map(c => c.value)
      .join('');
  }
}

Real-World CRDT Libraries

Don't implement this yourself for production. Use battle-tested libraries:

  • Yjs: The most popular, powers many collaborative apps
  • Automerge: Elegant API, great for JSON-like data
  • Diamond Types: Rust-based, extremely fast

Here's how simple Yjs makes collaborative text:

import * as Y from 'yjs';

const doc = new Y.Doc();
const text = doc.getText('shared');

// Local edits
text.insert(0, 'Hello');
text.insert(5, ' World');

// Sync with remote
doc.on('update', (update) => {
  // Send update to other peers
  broadcastToPeers(update);
});

// Apply remote update
function onRemoteUpdate(update: Uint8Array) {
  Y.applyUpdate(doc, update);
}

When to Use What?

Choose OT When:

  • You have a central server anyway
  • You need fine-grained control over conflict resolution
  • You're working with established systems like Google Docs
  • Bandwidth is limited (OT operations are typically smaller)

Choose CRDTs When:

  • You need peer-to-peer collaboration
  • Offline support is critical
  • You want simpler reasoning about correctness
  • You need to sync across many replicas without a central coordinator
  • You're building with modern libraries like Yjs

The Hybrid Approach

Many real systems combine both:

  • Figma: Uses CRDTs for the data model but custom algorithms for specific features
  • Apple Notes: CRDTs for sync, but server still coordinates
  • Linear: CRDTs for local-first sync with server-side ordering

Performance Considerations

OT

  • Transformation is O(n) where n is concurrent operations
  • Central server can become a bottleneck
  • History can be compacted aggressively

CRDTs

  • Tombstones accumulate (deleted items stick around)
  • Metadata overhead per character/element
  • Modern CRDTs like Yjs have excellent optimizations

Yjs benchmarks show it handling millions of operations efficiently, making CRDT overhead a non-issue for most applications.

Practical Recommendations

  1. Starting a new project? Use Yjs or Automerge. The DX is excellent, and you get offline-first for free.
  2. Building a text editor? Look at ProseMirror + Yjs or Slate + Yjs integrations.
  3. Need real-time cursors? Both approaches support awareness/presence—Yjs has this built in.
  4. Worried about conflicts? CRDTs make conflicts mathematically impossible. OT makes them resolvable.

Conclusion

OT and CRDTs solve the same fundamental problem: making concurrent edits converge. OT does it through transformation, CRDTs through mathematical properties.

For most new projects, I'd recommend starting with CRDTs via Yjs. The programming model is simpler, offline-first comes naturally, and the performance is excellent. OT still shines in server-centric architectures where you need maximum control.

The collaborative editing landscape has matured enormously. What once required PhD-level distributed systems knowledge now comes in npm packages. Pick your approach, grab a library, and start building.


Written by saumyatyagi | Loves cricket and distributed systems.
Published by HackerNoon on 2025/12/24