System Design ·

Building a Real-Time Collaboration Engine: CRDTs, Operational Transform, and Conflict Resolution

Concurrent editing in distributed systems is hard to get right. This guide compares Operational Transform and CRDTs with honest tradeoffs, walks through a TypeScript CRDT implementation, and covers the production concerns that most tutorials skip: undo/redo, offline support, tombstone garbage collection, and awareness state.

Building a Real-Time Collaboration Engine: CRDTs, Operational Transform, and Conflict Resolution

Two users open the same document. Both start typing at position 5. One inserts “hello”, the other inserts “world”. By the time their edits reach the server, the positions are stale. If you apply them naively, you corrupt the document. Every collaborative editor has to solve this, and the solution space is narrower than it looks.

There are two serious approaches: Operational Transform (OT) and Conflict-Free Replicated Data Types (CRDTs). Both work. Both have been deployed at scale. The choice between them determines the shape of your entire system.

The Core Problem

Think of a shared document as a sequence of characters. Each character has a position. When two clients edit concurrently, their position references diverge from the moment their network connections lag. If client A inserts a character at position 3, every subsequent position referenced by client B’s pending operations is wrong by one.

This is not a network problem. You cannot solve it by making the server faster or the clients more reliable. It is a logical problem: once two clients have diverged from a common state, their operations reference different realities. You need an algorithm to reconcile them.

The key insight both OT and CRDTs share: you cannot merge positions, but you can merge intentions.

Operational Transform

OT was the first practical solution, implemented in Google Wave and later refined for Google Docs. The idea: before applying a remote operation, transform it against all concurrent local operations to account for their effect on position.

If client A inserted a character at position 3, and client B then tries to insert at position 3 (or later), transform B’s operation by shifting its position forward by 1.

The transformation function for a simple insert-insert pair:

type InsertOp = { type: "insert"; pos: number; char: string; clientId: string };
type DeleteOp = { type: "delete"; pos: number; clientId: string };
type Op = InsertOp | DeleteOp;

function transformInsertAgainstInsert(op: InsertOp, against: InsertOp): InsertOp {
  if (against.pos < op.pos) {
    return { ...op, pos: op.pos + 1 };
  }
  if (against.pos === op.pos && against.clientId < op.clientId) {
    // Tiebreak by clientId to ensure convergence
    return { ...op, pos: op.pos + 1 };
  }
  return op;
}

function transformInsertAgainstDelete(op: InsertOp, against: DeleteOp): InsertOp {
  if (against.pos < op.pos) {
    return { ...op, pos: op.pos - 1 };
  }
  return op;
}

The challenge with OT is that transform functions must be written for every combination of operation types (insert-insert, insert-delete, delete-delete, move-insert, and so on). And they must satisfy the “convergence property”: given the same set of concurrent operations in any order, all clients must reach the same final state. Proving this property for complex operation sets is notoriously hard.

OT also requires a central server to establish a total ordering of operations. Without a server to serialize concurrent operations, you cannot guarantee convergence. This constraint shapes your architecture: OT systems are inherently server-centric.

CRDTs

CRDTs take a different approach. Instead of transforming operations to account for concurrent edits, design the data structure so that any two replicas can be merged deterministically, regardless of the order operations arrive.

A CRDT-based document does not store characters at positions. It stores characters with unique, stable identifiers. Position is derived from the identifier ordering, not stored directly.

The most widely deployed approach for text is the Logoot/LSEQ family and its successor, RGA (Replicated Growable Array), which underlies Yjs and Automerge.

In RGA, every character gets a unique identifier composed of a Lamport timestamp and the client ID:

type CharId = { clock: number; clientId: string };

type Char = {
  id: CharId;
  value: string | null; // null = tombstone (deleted)
  parentId: CharId | null; // the character this was inserted after
};

function compareCharIds(a: CharId, b: CharId): number {
  if (a.clock !== b.clock) return a.clock - b.clock;
  return a.clientId < b.clientId ? -1 : a.clientId > b.clientId ? 1 : 0;
}

When a client inserts a character, it records which character it was inserted after (the parentId). On merge, characters are sorted by their parent chain. If two clients insert after the same character concurrently, the tiebreak is the character ID comparison.

class RGADocument {
  private chars: Map<string, Char> = new Map();
  private clock: number = 0;
  private clientId: string;

  constructor(clientId: string) {
    this.clientId = clientId;
    // Sentinel root node
    const root: Char = { id: { clock: 0, clientId: "" }, value: null, parentId: null };
    this.chars.set(this.charKey(root.id), root);
  }

  private charKey(id: CharId): string {
    return `${id.clock}:${id.clientId}`;
  }

  insert(afterId: CharId, value: string): Char {
    this.clock++;
    const char: Char = {
      id: { clock: this.clock, clientId: this.clientId },
      value,
      parentId: afterId,
    };
    this.chars.set(this.charKey(char.id), char);
    return char;
  }

  delete(id: CharId): void {
    const char = this.chars.get(this.charKey(id));
    if (char) {
      char.value = null; // tombstone
    }
  }

  applyRemote(char: Char): void {
    if (!this.chars.has(this.charKey(char.id))) {
      this.chars.set(this.charKey(char.id), { ...char });
      if (char.id.clock > this.clock) {
        this.clock = char.id.clock;
      }
    }
  }

  toText(): string {
    return this.toSequence()
      .filter((c) => c.value !== null)
      .map((c) => c.value!)
      .join("");
  }

  private toSequence(): Char[] {
    // Build adjacency: parentId -> children sorted by id
    const children = new Map<string, Char[]>();
    for (const char of this.chars.values()) {
      const parentKey = char.parentId ? this.charKey(char.parentId) : "__root__";
      if (!children.has(parentKey)) children.set(parentKey, []);
      children.get(parentKey)!.push(char);
    }
    for (const list of children.values()) {
      list.sort((a, b) => compareCharIds(b.id, a.id)); // descending = right-to-left insertion order
    }

    const result: Char[] = [];
    const stack: string[] = ["__root__"];
    while (stack.length > 0) {
      const key = stack.pop()!;
      const node = this.chars.get(key);
      if (node && node.value !== undefined) result.push(node);
      const kids = children.get(key) ?? [];
      for (const kid of kids) {
        stack.push(this.charKey(kid.id));
      }
    }
    return result;
  }
}

The merge rule is simple: a character with a given ID exists or it does not. If you receive an operation for a character you already have, discard it. Convergence is guaranteed because the merge is commutative, associative, and idempotent.

OT vs CRDTs: Honest Tradeoffs

ConcernOTCRDT
Server requirementCentral server required for orderingPeer-to-peer or server-assisted
Merge complexityTransform functions per op-pairData structure complexity
Memory overheadLow (operations are compact)Higher (stable IDs per character)
Convergence proofHard to verify for complex schemasMathematically guaranteed
Offline supportAwkward, requires rebasingNatural, operations commute
Ecosystem maturityGoogle Docs, Notion (proprietary)Yjs, Automerge (open source)

OT is a better fit if you have an existing server-authoritative architecture and a limited set of operation types. CRDTs are a better fit if you need offline-first behavior, peer-to-peer sync, or a richer set of data types beyond text (maps, arrays, registers).

For most new systems, start with a CRDT library (Yjs is the most production-tested) rather than implementing from scratch.

Server Architecture for Sync

Even with CRDTs, you usually want a server. Not for ordering (CRDTs do not need it), but for persistence, initial document load, and broadcasting to connected clients efficiently.

import { WebSocketServer, WebSocket } from "ws";

type ClientState = {
  ws: WebSocket;
  documentId: string;
  clientId: string;
};

const rooms = new Map<string, Set<ClientState>>();

function broadcast(documentId: string, message: Buffer, exclude?: WebSocket) {
  const room = rooms.get(documentId);
  if (!room) return;
  for (const client of room) {
    if (client.ws !== exclude && client.ws.readyState === WebSocket.OPEN) {
      client.ws.send(message);
    }
  }
}

wss.on("connection", (ws) => {
  let state: ClientState | null = null;

  ws.on("message", async (data: Buffer) => {
    const msg = JSON.parse(data.toString());

    if (msg.type === "join") {
      state = { ws, documentId: msg.documentId, clientId: msg.clientId };
      if (!rooms.has(msg.documentId)) rooms.set(msg.documentId, new Set());
      rooms.get(msg.documentId)!.add(state);

      // Send current document state to new joiner
      const doc = await loadDocument(msg.documentId);
      ws.send(JSON.stringify({ type: "sync", state: doc }));
    }

    if (msg.type === "update" && state) {
      // Persist the update
      await persistUpdate(state.documentId, msg.update);
      // Relay to peers
      broadcast(state.documentId, data, ws);
    }

    if (msg.type === "awareness" && state) {
      // Awareness: cursor positions, selection, user presence
      broadcast(state.documentId, data, ws);
    }
  });

  ws.on("close", () => {
    if (state) {
      rooms.get(state.documentId)?.delete(state);
      broadcast(state.documentId, Buffer.from(JSON.stringify({
        type: "awareness",
        clientId: state.clientId,
        data: null, // null signals disconnect
      })));
    }
  });
});

For persistence, store CRDT updates as an append-only log per document. On initial sync, replay the log or serve a snapshot plus incremental updates since the snapshot.

async function persistUpdate(documentId: string, update: Uint8Array): Promise<void> {
  await db.execute(
    "INSERT INTO document_updates (document_id, update, created_at) VALUES (?, ?, ?)",
    [documentId, Buffer.from(update), Date.now()]
  );
}

async function loadDocument(documentId: string): Promise<Uint8Array[]> {
  const rows = await db.query(
    "SELECT update FROM document_updates WHERE document_id = ? ORDER BY created_at ASC",
    [documentId]
  );
  return rows.map((r) => new Uint8Array(r.update));
}

Awareness: Cursors and Presence

Awareness state (cursor positions, user names, online status) is distinct from document state. It is ephemeral and does not need convergence guarantees. Last-write-wins per client is correct: the most recent cursor position from client X is the right one.

Yjs has a dedicated awareness protocol for this. If you build your own, model it as a map from clientId to an arbitrary state blob with a monotonic counter:

type AwarenessState = {
  clientId: string;
  clock: number;
  data: {
    cursor: { anchor: number; head: number } | null;
    user: { name: string; color: string };
  } | null;
};

class AwarenessManager {
  private states = new Map<string, AwarenessState>();

  update(incoming: AwarenessState): boolean {
    const existing = this.states.get(incoming.clientId);
    if (existing && existing.clock >= incoming.clock) return false;
    if (incoming.data === null) {
      this.states.delete(incoming.clientId);
    } else {
      this.states.set(incoming.clientId, incoming);
    }
    return true;
  }

  getAll(): AwarenessState[] {
    return Array.from(this.states.values());
  }
}

Cursor positions reference character IDs, not array indices. This is critical: as the document changes, index-based cursor positions drift. A cursor anchored to a character ID stays in the right place even as characters are inserted around it.

Production Concerns

Undo/Redo

Undo in a collaborative editor is not “reverse the last operation on the document.” It is “reverse the last operation this user performed, without affecting other users’ changes.”

This requires tracking each client’s own operation history separately from the global document history. When the user hits undo, you generate an inverse operation for the most recent local change, apply it locally, and broadcast it. The inverse must account for changes other users made in the interim.

CRDTs make this harder, not easier: since operations are permanent (deletes become tombstones), undo of a delete means re-inserting a character with a new ID. Your application layer must track this mapping.

Offline Support

CRDTs handle offline naturally. A client that goes offline continues to accumulate operations locally. When it reconnects, it sends its pending operations to the server, which merges them and broadcasts to other clients.

The client must also receive any operations it missed while offline. A vector clock or document version counter lets the server determine what to send:

type SyncRequest = {
  documentId: string;
  clientId: string;
  knownClock: number; // client's last known server clock
};

async function getUpdatesAfter(documentId: string, clock: number): Promise<Uint8Array[]> {
  const rows = await db.query(
    "SELECT update FROM document_updates WHERE document_id = ? AND sequence > ? ORDER BY sequence ASC",
    [documentId, clock]
  );
  return rows.map((r) => new Uint8Array(r.update));
}

The tricky case is a long offline session where the server has garbage-collected old tombstones (see below). The client’s state may reference characters that no longer exist in the server’s view. You need a policy: either keep tombstones long enough to cover your expected offline window, or trigger a full document re-sync when the client’s state is too stale.

Document Size Growth

Every deleted character leaves a tombstone. A heavily edited document accumulates tombstones proportional to total edit count, not document length. A 10,000-character document with 100,000 edits carries 90,000 tombstones in memory.

This is the most common operational surprise in CRDT-based collaboration systems. Monitor tombstone ratio in production. A document where tombstones exceed 10x live characters is a red flag.

Mitigation strategies:

Snapshotting: Periodically serialize the visible document state (no tombstones) and reset the update log. Clients that join after the snapshot receive the clean state. Clients with pending unsynced changes from before the snapshot cannot merge cleanly and need conflict resolution at the application level.

Tombstone GC: A character’s tombstone can be removed once every connected client has acknowledged seeing the delete, and no client has been offline long enough to reference that character. This requires tracking per-client vector clocks and running GC only when all active clients are online and caught up. It is complex enough that most teams accept the memory cost and rely on snapshotting instead.

Compaction thresholds: Set a maximum update log size. When exceeded, create a snapshot and truncate the log. Alert on documents approaching the threshold before they hit it.

Operation Batching

Do not send one WebSocket message per keystroke. Buffer operations on the client for 50-100ms and send a batch. This reduces server load and makes the update log more manageable. Yjs already batches its updates; if you build your own, apply the same pattern:

class OperationBuffer {
  private pending: Op[] = [];
  private timer: ReturnType<typeof setTimeout> | null = null;

  add(op: Op, send: (ops: Op[]) => void): void {
    this.pending.push(op);
    if (!this.timer) {
      this.timer = setTimeout(() => {
        const batch = this.pending.splice(0);
        this.timer = null;
        if (batch.length > 0) send(batch);
      }, 50);
    }
  }

  flush(send: (ops: Op[]) => void): void {
    if (this.timer) {
      clearTimeout(this.timer);
      this.timer = null;
    }
    const batch = this.pending.splice(0);
    if (batch.length > 0) send(batch);
  }
}

Flush the buffer before the page unloads (beforeunload event) to avoid losing the last few keystrokes.

Closing Thoughts

The implementation complexity of collaborative editing sits almost entirely in the data structure layer, not the network layer. Once you pick a sound CRDT or OT algorithm, the network architecture is standard WebSocket fan-out with an append-only persistence model.

For most teams, the right starting point is Yjs with its existing provider ecosystem (WebSocket, WebRTC, IndexedDB). Build on top of a proven CRDT implementation before considering a custom one. The algorithm is the easy part. The tombstone GC, the offline merge edge cases, and the cursor tracking against stable character IDs are where real systems spend their engineering time.

If you do build your own, start with RGA. It is simpler than LSEQ, well-documented, and the invariants are easy to test. Write property-based tests that check convergence: given any two operation sequences applied in any order, the resulting documents must be identical. That test suite will find bugs your unit tests miss.

More in System Design

How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
System Design ·

How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL

A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.

How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
System Design ·

How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale

A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.

How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
System Design ·

How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation

Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.

How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
System Design ·

How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally

A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.