All posts
29 Aug 2026

Building Real-Time Collaborative Editing: Operational Transformation vs. CRDTs in Node.js

A code-heavy architectural guide implementing a central-server Operational Transformation (OT) engine in Node.js and WebSockets, comparing it directly against CRDTs.

Building Real-Time Collaborative Editing: Operational Transformation vs. CRDTs in Node.js

In a previous post, we explored how Conflict-free Replicated Data Types (CRDTs) solve the distributed state problem by making all updates commutative through mathematical lattice structures. While CRDTs are a powerhouse for peer-to-peer and decentralized systems, much of the industry still relies on a centralized architecture driven by Operational Transformation (OT).

From Google Docs to Etherpad, OT has been the battle-tested standard for real-time collaborative editing. In this architectural guide, we will dive deep into how OT works under the hood, compare its trade-offs directly with CRDTs, and build a fully functional central-server OT engine from scratch using Node.js and WebSockets.


OT vs. CRDTs: The Architectural Divide

Before writing a single line of code, let’s understand why you would choose OT over CRDTs (or vice versa) for a Node.js backend.

Feature Operational Transformation (OT) CRDTs (Conflict-free Replicated Data Types)
Topology Centralized (requires a central server/sequencer) Decentralized / P2P friendly
Memory Footprint Low (Server transforms operations and discards history) Higher (Metadata grows over time to resolve conflicts)
Complexity High transformation logic (transforms must satisfy TP2 property) Higher state size, simpler merge logic
Intent Preservation Excellent (designed explicitly for text spaces) Good, but can suffer from cursor anomalies without custom tombstones

The Core Philosophy

  • CRDTs shift complexity to the data structure. Every client can mutate state locally without coordination, and the math guarantees convergence.
  • OT shifts complexity to the control flow. Clients send raw user intents (e.g., “insert character ‘a’ at index 5”) to a central server. The server acts as a single source of truth, sequencing incoming operations and transforming them against concurrent operations so they execute in the correct order on all clients.

The Anatomy of an OT Engine

Our OT engine will consist of three fundamental components:

  1. Operations: Atomic actions on text (retain, insert, delete), heavily inspired by the Quill.js Delta format.
  2. Transformation (transform): A pure function that takes two concurrent operations ($A$ and $B$) generated at the same state, and produces $A’$ and $B’$ such that applying $A$ then $B’$ yields the exact same document state as applying $B$ then $A’$.
  3. Client-Server State Machine: Managing revisions, acknowledgments, and buffer queues for pending local operations.

Step 1: The Operation Model and Transformation Engine

Let’s start by defining our operation primitives and the transformation logic in JavaScript. For simplicity, our operations will be represented as arrays of components: retain(n), insert(str), and delete(n).

Create a file named ot.js:

javascript
// ot.js

class TextOperation {
  constructor() {
    this.ops = [];
  }

  retain(n) {
    if (n === 0) return this;
    this.ops.push({ retain: n });
    return this;
  }

  insert(str) {
    if (str === '') return this;
    this.ops.push({ insert: str });
    return this;
  }

  delete(n) {
    if (n === 0) return this;
    this.ops.push({ delete: n });
    return this;
  }

  // Transform operation op1 against concurrent operation op2
  // Returns [op1', op2']
  static transform(op1, op2) {
    const o1 = new Cursor(op1);
    const o2 = new Cursor(op2);
    const o1prime = new TextOperation();
    const o2prime = new TextOperation();

    while (o1.hasNext() || o2.hasNext()) {
      if (o1.isInsert()) {
        o1prime.insert(o1.nextInsert());
        o2prime.retain(o1.insertLength());
      } else if (o2.isInsert()) {
        o1prime.retain(o2.insertLength());
        o2prime.insert(o2.nextInsert());
      } else {
        const min = Math.min(o1.peek(), o2.peek());
        if (o1.isRetain() && o2.isRetain()) {
          o1prime.retain(min);
          o2prime.retain(min);
        } else if (o1.isDelete() && o2.isRetain()) {
          o1prime.delete(min);
          o2prime.retain(min);
        } else if (o1.isRetain() && o2.isDelete()) {
          o1prime.retain(min);
          o2prime.delete(min);
        } else if (o1.isDelete() && o2.isDelete()) {
          // Both delete the same characters; nothing to do
        }
        o1.consume(min);
        o2.consume(min);
      }
    }

    return [o1prime, o2prime];
  }
}

class Cursor {
  constructor(operation) {
    this.ops = operation.ops;
    this.index = 0;
    this.offset = 0;
  }

  hasNext() {
    return this.index < this.ops.length;
  }

  peek() {
    const op = this.ops[this.index];
    if (op.retain) return op.retain - this.offset;
    if (op.delete) return op.delete - this.offset;
    if (op.insert) return op.insert.length - this.offset;
    throw new Error('Unknown operation type');
  }

  isRetain() {
    return this.index < this.ops.length && 'retain' in this.ops[this.index];
  }

  isInsert() {
    return this.index < this.ops.length && 'insert' in this.ops[this.index];
  }

  isDelete() {
    return this.index < this.ops.length && 'delete' in this.ops[this.index];
  }

  nextInsert() {
    const op = this.ops[this.index];
    const str = op.insert.slice(this.offset);
    this.index++;
    this.offset = 0;
    return str;
  }

  insertLength() {
    const op = this.ops[this.index];
    return op.insert.length - this.offset;
  }

  consume(n) {
    const op = this.ops[this.index];
    const length = op.retain || op.delete || op.insert.length;
    if (n === length - this.offset) {
      this.index++;
      this.offset = 0;
    } else {
      this.offset += n;
    }
  }
}

module.exports = { TextOperation };

Step 2: Building the Node.js WebSocket Server

The central server is responsible for maintaining the canonical document state, tracking the current revision number, and transforming incoming client operations against any history the client missed.

Initialize a new project and install dependencies (ws):

npm init -y
npm install ws

Now, implement server.js:

// server.js
const WebSocket = require('ws');
const { TextOperation } = require('./ot');

const wss = new WebSocket.Server({ port: 8080 });

class DocumentServer {
  constructor() {
    this.document = "";
    this.revision = 0;
    this.history = [];
    this.clients = new Set();
  }

  handleConnection(ws) {
    this.clients.add(ws);

    // Send initial state
    ws.send(JSON.stringify({
      type: 'init',
      document: this.document,
      revision: this.revision
    }));

    ws.on('message', (message) => {
      const data = JSON.parse(message);
      if (data.type === 'operation') {
        this.processOperation(ws, data);
      }
    });

    ws.on('close', () => {
      this.clients.delete(ws);
    });
  }

  processOperation(senderWs, data) {
    let clientRev = data.revision;
    let clientOp = new TextOperation();
    clientOp.ops = data.operation;

    // Validate revision
    if (clientRev < 0 || clientRev > this.revision) {
      senderWs.send(JSON.stringify({ type: 'error', message: 'Stale revision' }));
      return;
    }

    // Transform client operation against all operations that occurred
    // between the client's revision and the current server revision.
    for (let i = clientRev; i < this.revision; i++) {
      const serverOp = this.history[i];
      const [transformedClientOp, transformedServerOp] = TextOperation.transform(clientOp, serverOp);
      clientOp = transformedClientOp;
      // Note: In a production server, you also transform serverOp against clientOp
      // to update history if multiple concurrent streams require it.
    }

    // Apply to canonical document state
    this.document = this.applyOperation(this.document, clientOp);
    this.history.push(clientOp);
    this.revision++;

    // Broadcast to all clients (including sender, or acknowledge sender)
    const payload = JSON.stringify({
      type: 'operation',
      revision: this.revision - 1,
      operation: clientOp.ops
    });

    for (const client of this.clients) {
      if (client.readyState === WebSocket.OPEN) {
        client.send(payload);
      }
    }
  }

  applyOperation(doc, op) {
    let index = 0;
    let result = "";
    for (const component of op.ops) {
      if (component.retain) {
        result += doc.slice(index, index + component.retain);
        index += component.retain;
      } else if (component.insert) {
        result += component.insert;
      } else if (component.delete) {
        index += component.delete;
      }
    }
    result += doc.slice(index);
    return result;
  }
}

const docServer = new DocumentServer();

wss.on('connection', (ws) => {
  docServer.handleConnection(ws);
});

console.log('OT WebSocket server running on ws://localhost:8080');

Step 3: Client Synchronization Architecture

A resilient OT client must handle the Awaiting Acknowledgement state. When a user types locally while an operation is already in flight to the server, the client must buffer those changes or queue them up.

Here is how the client state machine operates conceptually:

[Synchronized] --(User types)--> [Awaiting Ack] --(Ack received)--> [Synchronized]
       ^                             |
       |----(Concurrent ops arrive)--|

Let’s write a simple Node.js client script (client.js) to test our OT pipeline:

// client.js
const WebSocket = require('ws');
const { TextOperation } = require('./ot');

const ws = new WebSocket('ws://localhost:8080');

let document = "";
let revision = 0;
let state = 'SYNCHRONIZED'; // SYNCHRONIZED, AWAITING_CONFIRM, QUEUED
let pendingOp = null;
let bufferOp = null;

ows.on('open', () => {
  console.log('Connected to OT server');
  
  // Simulate a local user typing after connection
  setTimeout(() => {
    localInsert(0, "Hello World");
  }, 1000);
});

ows.on('message', (message) => {
  const data = JSON.parse(message);

  if (data.type === 'init') {
    document = data.document;
    revision = data.revision;
    console.log(`[INIT] Document: "${document}", Rev: ${revision}`);
  } else if (data.type === 'operation') {
    let remoteOp = new TextOperation();
    remoteOp.ops = data.operation;

    if (state === 'AWAITING_CONFIRM') {
      // Transform pending against remote
      const [transformedPending, transformedRemote] = TextOperation.transform(pendingOp, remoteOp);
      pendingOp = transformedPending;
      remoteOp = transformedRemote;
    } else if (state === 'QUEUED') {
      const [tp, tr1] = TextOperation.transform(pendingOp, remoteOp);
      pendingOp = tp;
      const [tb, tr2] = TextOperation.transform(bufferOp, tr1);
      bufferOp = tb;
      remoteOp = tr2;
    }

    document = applyOperationLocally(document, remoteOp);
    revision = data.revision + 1;

    if (data.revision === revision - 1 && state === 'AWAITING_CONFIRM') {
      // Our operation was acknowledged
      if (bufferOp) {
        pendingOp = bufferOp;
        bufferOp = null;
        state = 'AWAITING_CONFIRM';
        sendOperation(pendingOp);
      } else {
        state = 'SYNCHRONIZED';
        pendingOp = null;
      }
    }
    
    console.log(`[SYNC] Updated Document: "${document}", Rev: ${revision}`);
  }
});

function localInsert(index, str) {
  const op = new TextOperation().retain(index).insert(str);
  document = applyOperationLocally(document, op);

  if (state === 'SYNCHRONIZED') {
    pendingOp = op;
    state = 'AWAITING_CONFIRM';
    sendOperation(pendingOp);
  } else if (state === 'AWAITING_CONFIRM') {
    bufferOp = op;
    state = 'QUEUED';
  }
}

function sendOperation(op) {
  ws.send(JSON.stringify({
    type: 'operation',
    revision: revision,
    operation: op.ops
  }));
  console.log('[SEND] Sent operation at revision', revision);
}

function applyOperationLocally(doc, op) {
  let index = 0;
  let result = "";
  for (const component of op.ops) {
    if (component.retain) {
      result += doc.slice(index, index + component.retain);
      index += component.retain;
    } else if (component.insert) {
      result += component.insert;
    } else if (component.delete) {
      index += component.delete;
    }
  }
  result += doc.slice(index);
  return result;
}

Practical Considerations & Edge Cases in Production

While our implementation demonstrates the core mechanics, writing production-grade OT requires addressing several severe engineering hurdles:

1. The TP2 Problem

Operational Transformation is notoriously difficult to get right. If you have three concurrent operations ($A$, $B$, and $C$), naive pairwise transformations can fail to converge unless the transformation function satisfies specific algebraic properties (namely TP1 and TP2). Libraries like ShareJS or automerge (when using OT modes) expend thousands of lines of test cases strictly proving these convergence invariants.

2. Garbage Collection of History

In our DocumentServer implementation, the this.history array grows indefinitely. In a high-traffic production application, this will cause a memory leak. You must implement history pruning: once all connected clients have acknowledged revision $N$, you can safely purge history entries older than $N$.

3. Connection Drops and Reconnection

Mobile and unstable web clients disconnect frequently. When a client reconnects, the server must look at the client’s last known persistent revision, fetch the delta stream from history, replay transformations, and push the catch-up payload down the socket.


Conclusion: When to Use OT vs. CRDTs

  • Choose OT if: You are building a rich text editor where exact cursor positioning, formatting preservation, and strict central server moderation (e.g., authentication gating or permission checks per operation) are mandatory. The lower memory overhead on the client makes it ideal for resource-constrained browsers.
  • Choose CRDTs if: You are building local-first software, peer-to-peer data syncing, or applications that must tolerate prolonged network partitions without a central server orchestrating every keystroke.

Both patterns solve the hard math of distributed systems, but mastering OT gives you deep insight into how real-time collaborative web giants have scaled document editing for over a decade.

More posts