All posts
1 Sep 2026

Building a Real-Time Collaborative Document Editor: Implementing Operational Transformation (OT) in Node.js

A practical, code-heavy architectural guide on building a custom Operational Transformation engine from scratch in Node.js and WebSockets to handle concurrent text edits and conflict resolution.

Building a real-time collaborative document editor like Google Docs requires solving one of the most fascinating problems in distributed systems: conflict resolution. When two users simultaneously edit the same document from different parts of the world, how do you ensure their changes merge seamlessly without data loss?

The industry-standard approach for text-based collaboration is Operational Transformation (OT). Unlike coarse-grained locking or simple Last-Write-Wins strategies, OT allows concurrent modifications to be transformed and applied in real-time, preserving the intent of every user.

In this architectural guide, we will build a custom, minimalist OT engine from scratch using Node.js, WebSockets, and modern JavaScript.


Understanding the Core Concepts of OT

At its heart, Operational Transformation is a set of algorithms for concurrency control. When a client makes a change, it doesn’t send the entire document state. Instead, it sends an Operation.

Operations

An operation represents an atomic change to a text document. For simplicity, our engine will support three primitive operations on a string:

  1. Retain ($n$): Skip $n$ characters.
  2. Insert ($s$): Insert string $s$ at the current cursor position.
  3. Delete ($n$): Delete $n$ characters at the current cursor position.

An operation is represented as an array of these components. For example, the operation to skip 5 characters and then insert “hello” is [5, 'hello'].

The Concurrency Problem

Imagine a document containing the initial text: "cat".

  • User A inserts "s" at index 3, changing the text to "cats" (Operation $A$: [3, 's']).
  • User B inserts "s" at index 0, changing the text to "ccat" (Operation $B$: [0, 's']).

If the server receives Operation $B$ first, applies it, and then receives Operation $A$, simply applying Operation $A$ directly to the new state ("ccat" at index 3) will yield an incorrect result ("ccats" instead of "scats").

This is where Transformation comes in. Before applying Operation $A$ against a state that has already been modified by Operation $B$, we must transform $A$ against $B$ to produce $A’$ ($A$ prime), adjusting its indices to account for $B$.

code
Client A ──(Op A)──> [Server] ──(Transform A against B)──> Apply A'
Client B ──(Op B)──> [Server] ───────────────────────────> Apply B

Setting Up the Node.js Server

Let’s build our backend using Node.js and the ws library for WebSocket management. Initialize a new Node project and install the dependency:

npm init -y
npm install ws

Document and Client State Management

We need a central server structure that maintains the canonical document text, a revision history, and connected clients.

// server.js
const WebSocket = require('express-ws'); // or native 'ws'
const { WebSocketServer } = require('ws');

const wss = new WebSocketServer({ port: 8080 });

class DocumentServer {
    constructor(initialText) {
        this.document = initialText;
        this.revision = 0;
        this.clients = new Set();
        this.history = []; // Stores past operations for transforming late arrivals
    }

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

        // Send initial state to the newly connected client
        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.handleOperation(ws, data);
            }
        });

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

    handleOperation(senderWs, data) {
        let { clientRevision, operation } = data;

        // If the client's revision is behind the server's revision,
        // we must transform the incoming operation against all intervening operations.
        if (clientRevision < this.revision) {
            const concurrentOps = this.history.slice(clientRevision);
            for (const serverOp of concurrentOps) {
                // [transformedOp, transformedServerOp] = transform(operation, serverOp)
                const result = transform(operation, serverOp);
                operation = result[0];
            }
        }

        // Apply the transformed operation to the canonical document
        this.document = applyOperation(this.document, operation);
        this.revision++;
        this.history.push(operation);

        // Broadcast the operation to all other clients
        const response = JSON.stringify({
            type: 'operation',
            revision: this.revision,
            operation,
        });

        for (const client of this.clients) {
            if (client !== senderWs && client.readyState === WebSocket.OPEN) {
                client.send(response);
            }
        }

        // Acknowledge the sender
        senderWs.send(
            JSON.stringify({
                type: 'ack',
                revision: this.revision,
            })
        );
    }
}

const docServer = new DocumentServer('Hello World');

WSS.on('connection', (ws) => {
    docServer.addClient(ws);
});

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

Implementing the Operation Engine

Now we need the pure functions that make OT work: applyOperation and the core transform function.

1. Applying Operations

An operation is an array of components (e.g., [5, 'abc', 3]). Applying an operation means executing these steps against a target string.

function applyOperation(str, operation) {
    let result = '';
    let index = 0;

    for (const component of operation) {
        if (typeof component === 'number') {
            // Retain component
            if (index + component > str.length) {
                throw new Error('Retain index out of bounds');
            }
            result += str.slice(index, index + component);
            index += component;
        } else if (typeof component === 'string') {
            // Insert component
            result += component;
        } else if (typeof component === 'object' && component.delete) {
            // Delete component (represented as an object for clarity)
            index += component.delete;
        }
    }

    // Append remaining characters if any
    if (index < str.length) {
        result += str.slice(index);
    }

    return result;
}

2. The Transformation Algorithm

Transformation takes two concurrent operations, $A$ and $B$, which were generated from the same document state, and returns $A’$ and $B’$ such that applying $A$ then $B’$ yields the exact same final document state as applying $B$ then $A’$.

Below is a simplified implementation handling basic retain and insert transformations:

function transform(op1, op2) {
    // For brevity, we implement a robust 1-to-1 transform for Insert/Insert conflicts
    // Real-world OT engines handle arbitrary combinations of Retain, Insert, and Delete.
    
    let o1 = [...op1];
    let o2 = [...op2];
    
    let c1 = o1.shift();
    let c2 = o2.shift();

    let transformedOp1 = [];
    let transformedOp2 = [];

    while (c1 !== undefined && c2 !== undefined) {
        // Case 1: Both operations are insertions at the same or relative points
        if (typeof c1 === 'string' && typeof c2 === 'string') {
            // Tie-breaking mechanism: use client ID or arbitrary string comparison
            if (c1 < c2) {
                transformedOp1.push(c1);
                transformedOp2.push(c1.length);
                c1 = o1.shift();
            } else {
                transformedOp1.push(c2.length);
                transformedOp2.push(c2);
                c2 = o2.shift();
            }
        } 
        // Case 2: op1 inserts, op2 retains
        else if (typeof c1 === 'string' && typeof c2 === 'number') {
            transformedOp1.push(c1);
            transformedOp2.push(c1.length);
            c1 = o1.shift();
        }
        // Case 3: op1 retains, op2 inserts
        else if (typeof c1 === 'number' && typeof c2 === 'string') {
            transformedOp1.push(c2.length);
            transformedOp2.push(c2);
            c2 = o2.shift();
        }
        // Case 4: Both are retain components
        else if (typeof c1 === 'number' && typeof c2 === 'number') {
            if (c1 < c2) {
                transformedOp1.push(c1);
                transformedOp2.push(c1);
                c2 -= c1;
                c1 = o1.shift();
            } else if (c1 > c2) {
                transformedOp1.push(c2);
                transformedOp2.push(c2);
                c1 -= c2;
                c2 = o2.shift();
            } else {
                transformedOp1.push(c1);
                transformedOp2.push(c2);
                c1 = o1.shift();
                c2 = o2.shift();
            }
        }
    }

    return [transformedOp1, transformedOp2];
}

Production Note: Writing a fully general OT algorithm that handles all edge cases across Retain, Insert, and Delete operations is notoriously complex. In production environments, developers frequently leverage battle-tested open-source libraries like ShareJS, Ot.js, or transition to CRDTs (Conflict-free Replicated Data Types) like Yjs or Automerge.


Client-Side Architecture and State Machine

To prevent UI stuttering while waiting for network round-trips, a robust client implementation must maintain three states:

  1. Synchronized: The client has no pending operations inflight. Everything is acknowledged by the server.
  2. Awaiting: The client has sent an operation and is waiting for server acknowledgement (ack). Local edits made during this state are buffered into a buffer state.
  3. Awaiting with Buffer: The client is waiting for an ACK, but the user typed more characters. Once the ACK returns, the buffer is dispatched as a new operation.
class DocumentClient {
    constructor(wsUrl) {
        this.ws = new WebSocket(wsUrl);
        this.revision = 0;
        this.state = 'SYNCHRONIZED';
        this.pendingOperation = null;
        this.bufferOperation = null;

        this.ws.onmessage = (event) => this.handleMessage(JSON.parse(event.data));
    }

    handleMessage(data) {
        switch (data.type) {
            case 'init':
                this.revision = data.revision;
                console.log('Document initialized:', data.document);
                break;
            case 'ack':
                this.revision = data.revision;
                if (this.bufferOperation) {
                    // Send buffered changes
                    this.pendingOperation = this.bufferOperation;
                    this.bufferOperation = null;
                    this.sendOperation(this.pendingOperation);
                } else {
                    this.pendingOperation = null;
                    this.state = 'SYNCHRONIZED';
                }
                break;
            case 'operation':
                // Transform incoming remote operations against local pending/buffered states
                this.revision = data.revision;
                // Apply to local DOM/editor view...
                break;
        }
    }

    sendOperation(op) {
        this.ws.send(
            JSON.stringify({
                type: 'operation',
                clientRevision: this.revision,
                operation: op,
            })
        );
    }
}

Summary of Architectural Trade-offs

Feature Operational Transformation (OT) Conflict-free Replicated Data Types (CRDTs)
Architecture Centralized (Requires a central server) Decentralized / Peer-to-Peer capable
Payload Size Small (Operations are compact) Larger (Metadata overhead for state tracking)
Implementation Complexity High (Transform functions are notoriously bug-prone) Medium-High (State-based or Operation-based CRDT math)
Use Case Document editors with strict ordering needs Offline-first apps, local-first software

By building an OT engine from scratch in Node.js, you gain a deep appreciation for the mechanics of real-time synchronization. Whether you choose to implement custom OT or drop in a modern CRDT library like Yjs, understanding how operations are transformed and applied concurrently is essential for building robust collaborative systems.

More posts