← Back to DevBytes

How to Build a Real-Time Collaboration Tool with Operational Transform

Understanding Operational Transform

Operational Transform (OT) is a class of algorithms that enable multiple users to edit a shared document simultaneously without conflicts. When two users make changes at the same time, OT mathematically transforms those operations so that both users end up with the same consistent document state, regardless of network latency or the order in which operations arrive.

At its core, OT takes two operations that were applied to different versions of a document and transforms them so they can be applied sequentially without causing divergence. This is what powers collaborative editors like Google Docs, Etherpad, and Microsoft Office Online.

Why Operational Transform Matters

Without OT, real-time collaboration would require pessimistic locking—only one user could edit at a time—or naive merging that often produces garbled text. OT solves the fundamental challenge of distributed consistency: it guarantees that all replicas converge to the identical final state despite concurrent edits, without requiring a central lock or forcing users to manually resolve conflicts.

The key benefits of OT include:

Core Concepts: Operations and Transformation Functions

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Before diving into code, let's establish the foundational building blocks. In OT, every edit is represented as an operation against a sequence of items (typically characters in a string). The most common operations are:

The magic of OT lives in the transformation function. Given two operations A and B that both originated from the same document state, the transformation produces A' (a version of A that can be applied after B) and B' (a version of B that can be applied after A). When applied in sequence—A then B' or B then A'—both paths yield identical results.

Representing Operations in Code

Let's define our operation types in TypeScript. We'll use a simple array-based representation where each element is either a retain, insert, or delete component:

// types.ts
export type OpComponent = 
  | { type: 'retain'; count: number }
  | { type: 'insert'; chars: string }
  | { type: 'delete'; count: number };

export type Operation = OpComponent[];

// Example operation: delete 3 chars, insert "hello", retain 2 chars
const exampleOp: Operation = [
  { type: 'delete', count: 3 },
  { type: 'insert', chars: 'hello' },
  { type: 'retain', count: 2 }
];

Each operation is a sequence of components that describe how to traverse and modify the document. The operation above means: "skip 3 characters by deleting them, then insert the string 'hello', then move forward 2 characters without changing them." The retain count at the end ensures the operation composes correctly with future operations.

Applying Operations to a Document

Before we can transform operations, we need a function that applies a single operation to a document string. This is straightforward:

// apply.ts
import { Operation } from './types';

export function applyOperation(doc: string, op: Operation): string {
  let cursor = 0;
  let result = '';

  for (const component of op) {
    switch (component.type) {
      case 'retain':
        // Advance cursor and copy retained characters
        result += doc.slice(cursor, cursor + component.count);
        cursor += component.count;
        break;
      case 'insert':
        // Insert new characters at the current cursor position
        result += component.chars;
        // Cursor doesn't advance through the original document
        break;
      case 'delete':
        // Skip over deleted characters (they're not added to result)
        cursor += component.count;
        break;
    }
  }

  // Append any remaining characters after the operation
  result += doc.slice(cursor);
  return result;
}

// Quick test
const doc = "Hello world";
const op: Operation = [
  { type: 'retain', count: 6 },      // keep "Hello "
  { type: 'delete', count: 5 },       // remove "world"
  { type: 'insert', chars: 'OT' }     // insert "OT"
];
console.log(applyOperation(doc, op)); // "Hello OT"

The apply function walks through each component, maintaining a cursor into the original document. Retain moves the cursor and copies characters; insert adds new characters without advancing the cursor; delete advances the cursor without copying. This sequential processing ensures the operation's effect is deterministic.

Building the Transformation Engine

The heart of OT is the transformation function. It takes two operations that were created from the same document state and produces two new operations that can be applied in sequence. Let's build this step by step.

Transforming Retain Against Retain

The simplest case: both operations have retain components with the same or different lengths. The transformed result preserves the minimum shared length, and any excess is handled separately:

// transform.ts — Part 1: Retain vs Retain
import { OpComponent, Operation } from './types';

function transformComponent(
  compA: OpComponent,
  compB: OpComponent
): { transformedA: OpComponent | null; transformedB: OpComponent | null; remainderA?: OpComponent; remainderB?: OpComponent } {
  
  // Case 1: Retain vs Retain
  if (compA.type === 'retain' && compB.type === 'retain') {
    const minCount = Math.min(compA.count, compB.count);
    if (minCount > 0) {
      return {
        transformedA: { type: 'retain', count: minCount },
        transformedB: { type: 'retain', count: minCount },
        remainderA: compA.count > minCount ? { type: 'retain', count: compA.count - minCount } : undefined,
        remainderB: compB.count > minCount ? { type: 'retain', count: compB.count - minCount } : undefined
      };
    }
    return { transformedA: null, transformedB: null };
  }
  
  // Additional cases will follow...
  throw new Error('Unhandled component combination');
}

When both operations want to retain characters, they can both retain the overlapping portion. Any extra retain length becomes a remainder that will be paired with the next component of the other operation.

Transforming Insert Against Insert

When two users insert text at the same position, we need to decide which insertion comes first. The standard approach uses the concept of precedence—one operation is designated to go first, and the other's insert is shifted forward by the length of the first insert:

// transform.ts — Part 2: Insert vs Insert and Insert vs Retain

function transformComponentPair(
  compA: OpComponent,
  compB: OpComponent,
  precedence: 'A' | 'B'
): { transformedA: OpComponent[]; transformedB: OpComponent[] } {

  // Case: Retain vs Retain (continued from above)
  if (compA.type === 'retain' && compB.type === 'retain') {
    const minCount = Math.min(compA.count, compB.count);
    if (minCount > 0) {
      return {
        transformedA: [{ type: 'retain', count: minCount }],
        transformedB: [{ type: 'retain', count: minCount }]
      };
    }
    return { transformedA: [], transformedB: [] };
  }

  // Case: Insert vs Insert (same position)
  if (compA.type === 'insert' && compB.type === 'insert') {
    if (precedence === 'A') {
      // A's insert goes first; B's insert must shift past A's characters
      return {
        transformedA: [{ type: 'retain', count: compA.chars.length }],
        transformedB: [
          { type: 'retain', count: compA.chars.length },
          { type: 'insert', chars: compB.chars }
        ]
      };
    } else {
      // B's insert goes first; A's insert must shift past B's characters
      return {
        transformedA: [
          { type: 'retain', count: compB.chars.length },
          { type: 'insert', chars: compA.chars }
        ],
        transformedB: [{ type: 'retain', count: compB.chars.length }]
      };
    }
  }

  // Case: Insert vs Retain
  if (compA.type === 'insert' && compB.type === 'retain') {
    // A inserts, B retains: A's insert stays, B's retain shifts past A's insert
    return {
      transformedA: [{ type: 'insert', chars: compA.chars }],
      transformedB: [
        { type: 'retain', count: compA.chars.length },
        { type: 'retain', count: compB.count }
      ]
    };
  }

  if (compA.type === 'retain' && compB.type === 'insert') {
    // A retains, B inserts: B's insert stays, A's retain shifts past B's insert
    return {
      transformedA: [
        { type: 'retain', count: compB.chars.length },
        { type: 'retain', count: compA.count }
      ],
      transformedB: [{ type: 'insert', chars: compB.chars }]
    };
  }

  // Case: Delete vs Delete (overlapping deletes)
  if (compA.type === 'delete' && compB.type === 'delete') {
    const minCount = Math.min(compA.count, compB.count);
    // Both want to delete overlapping region — delete only once
    return {
      transformedA: [{ type: 'retain', count: compA.count - minCount }],
      transformedB: [{ type: 'retain', count: compB.count - minCount }]
    };
  }

  // Case: Delete vs Retain
  if (compA.type === 'delete' && compB.type === 'retain') {
    return {
      transformedA: [{ type: 'delete', count: compA.count }],
      transformedB: [{ type: 'retain', count: compB.count - Math.min(compB.count, compA.count) }]
    };
  }

  if (compA.type === 'retain' && compB.type === 'delete') {
    return {
      transformedA: [{ type: 'retain', count: compA.count - Math.min(compA.count, compB.count) }],
      transformedB: [{ type: 'delete', count: compB.count }]
    };
  }

  // Case: Insert vs Delete — the insert shifts to avoid deleted region
  if (compA.type === 'insert' && compB.type === 'delete') {
    return {
      transformedA: [{ type: 'retain', count: compB.count }, { type: 'insert', chars: compA.chars }],
      transformedB: [{ type: 'delete', count: compB.count }]
    };
  }

  if (compA.type === 'delete' && compB.type === 'insert') {
    return {
      transformedA: [{ type: 'delete', count: compA.count }],
      transformedB: [{ type: 'retain', count: compA.count }, { type: 'insert', chars: compB.chars }]
    };
  }

  throw new Error(`Unhandled component pair: ${compA.type} vs ${compB.type}`);
}

Each case encodes a simple rule about how concurrent edits interact. Insert against insert uses precedence to break the tie. Delete against delete merges overlapping deletions. Insert against delete shifts the insert to the side of the deletion so it survives. These rules guarantee convergence.

Full Operation Transformation

Now we wrap the component-level transformation into a function that processes entire operations. We iterate through both operations component by component, pairing them up and handling remainders:

// transform.ts — Part 3: Full transformation function

export function transform(
  opA: Operation,
  opB: Operation,
  precedence: 'A' | 'B' = 'A'
): { transformedA: Operation; transformedB: Operation } {
  
  let idxA = 0, idxB = 0;
  let compA: OpComponent | null = opA[0] || null;
  let compB: OpComponent | null = opB[0] || null;
  
  const resultA: OpComponent[] = [];
  const resultB: OpComponent[] = [];

  // Process components in parallel, handling remainders
  while (compA || compB) {
    if (!compA && compB) {
      // Only B remains — A is done, so A gets nothing, B keeps its component
      resultB.push(compB);
      idxB++;
      compB = opB[idxB] || null;
      continue;
    }
    if (!compB && compA) {
      resultA.push(compA);
      idxA++;
      compA = opA[idxA] || null;
      continue;
    }
    
    // Both components exist — pair them
    if (compA && compB) {
      // Handle retain-retain with potential remainders
      if (compA.type === 'retain' && compB.type === 'retain') {
        const minCount = Math.min(compA.count, compB.count);
        if (minCount > 0) {
          resultA.push({ type: 'retain', count: minCount });
          resultB.push({ type: 'retain', count: minCount });
        }
        // Update remainders
        if (compA.count > minCount) {
          compA = { type: 'retain', count: compA.count - minCount };
        } else {
          idxA++;
          compA = opA[idxA] || null;
        }
        if (compB.count > minCount) {
          compB = { type: 'retain', count: compB.count - minCount };
        } else {
          idxB++;
          compB = opB[idxB] || null;
        }
        continue;
      }
      
      // For all other pairings, consume one component fully
      const paired = transformComponentPair(compA, compB, precedence);
      resultA.push(...paired.transformedA);
      resultB.push(...paired.transformedB);
      
      // Advance both indices since non-retain components are consumed entirely
      if (compA.type !== 'retain' || (compA.type === 'retain' && compA.count === 0)) {
        idxA++;
        compA = opA[idxA] || null;
      }
      if (compB.type !== 'retain' || (compB.type === 'retain' && compB.count === 0)) {
        idxB++;
        compB = opB[idxB] || null;
      }
    }
  }

  // Normalize: merge adjacent retain components
  function normalize(op: OpComponent[]): Operation {
    const normalized: OpComponent[] = [];
    for (const comp of op) {
      if (comp.type === 'retain' && normalized.length > 0 && normalized[normalized.length - 1].type === 'retain') {
        normalized[normalized.length - 1].count += comp.count;
      } else {
        normalized.push(comp);
      }
    }
    return normalized.filter(c => (c.type === 'retain' ? c.count > 0 : true));
  }

  return {
    transformedA: normalize(resultA),
    transformedB: normalize(resultB)
  };
}

This function walks through both operations in lockstep. When it encounters a retain-retain pair, it handles the overlap and carries forward any remainder. For all other pairings, it consumes one component from each operation and produces transformed components. The normalization pass merges adjacent retains to keep operations clean.

Testing the Transformation Engine

Let's verify our implementation with a concrete test. Two users edit "Hello World" simultaneously:

// test-transform.ts
import { applyOperation } from './apply';
import { transform } from './transform';
import { Operation } from './types';

const initialDoc = "Hello World";

// User A: changes "World" to "OT" (retain 6, delete 5, insert "OT")
const opA: Operation = [
  { type: 'retain', count: 6 },
  { type: 'delete', count: 5 },
  { type: 'insert', chars: 'OT' }
];

// User B: inserts "Beautiful " after "Hello " (retain 6, insert "Beautiful ")
const opB: Operation = [
  { type: 'retain', count: 6 },
  { type: 'insert', chars: 'Beautiful ' }
];

// Transform: A gets precedence
const { transformedA, transformedB } = transform(opA, opB, 'A');

console.log('Transformed A (to apply after B):', JSON.stringify(transformedA));
console.log('Transformed B (to apply after A):', JSON.stringify(transformedB));

// Path 1: Apply A first, then transformed B
const afterA = applyOperation(initialDoc, opA);         // "Hello OT"
const path1 = applyOperation(afterA, transformedB);      // Should be "Hello Beautiful OT"

// Path 2: Apply B first, then transformed A
const afterB = applyOperation(initialDoc, opB);         // "Hello Beautiful World"
const path2 = applyOperation(afterB, transformedA);      // Should be "Hello Beautiful OT"

console.log('Path 1 result:', path1);  // "Hello Beautiful OT"
console.log('Path 2 result:', path2);  // "Hello Beautiful OT"
console.log('Convergence:', path1 === path2); // true

Both paths produce "Hello Beautiful OT", proving that our transformation converges. This is the essential property of OT: regardless of the order operations are applied, all users end up with the same document.

Building the Real-Time Collaboration Server

With the transformation engine working, we need a server to coordinate operations. The server maintains the authoritative document state and a version history. When an operation arrives, the server transforms it against any concurrent operations, applies it, and broadcasts the transformed version to other clients.

Server Architecture

The server needs three core components:

// server.ts
import { applyOperation } from './apply';
import { transform } from './transform';
import { Operation } from './types';

interface ClientState {
  id: string;
  version: number;
  pendingOps: Operation[];
}

export class CollaborationServer {
  private document: string;
  private version: number;
  private operations: Map; // version -> operation
  private clients: Map;
  private broadcast: (clientId: string, op: Operation, version: number) => void;

  constructor(
    initialDocument: string,
    broadcastFn: (clientId: string, op: Operation, version: number) => void
  ) {
    this.document = initialDocument;
    this.version = 0;
    this.operations = new Map();
    this.clients = new Map();
    this.broadcast = broadcastFn;
  }

  addClient(clientId: string): void {
    this.clients.set(clientId, {
      id: clientId,
      version: this.version,
      pendingOps: []
    });
  }

  removeClient(clientId: string): void {
    this.clients.delete(clientId);
  }

  getDocument(): string {
    return this.document;
  }

  getVersion(): number {
    return this.version;
  }

  // Receive an operation from a client
  receiveOperation(clientId: string, op: Operation, clientVersion: number): void {
    const client = this.clients.get(clientId);
    if (!client) {
      console.warn(`Unknown client: ${clientId}`);
      return;
    }

    // 1. Transform the incoming operation against operations the client missed
    let transformedOp = op;
    let currentVersion = clientVersion;

    while (currentVersion < this.version) {
      const serverOp = this.operations.get(currentVersion);
      if (!serverOp) {
        console.error(`Missing operation at version ${currentVersion}`);
        break;
      }
      // Transform: incoming op gets precedence 'A', server op gets 'B'
      const result = transform(transformedOp, serverOp, 'A');
      transformedOp = result.transformedA;
      currentVersion++;
    }

    // 2. Apply the transformed operation to the document
    this.document = applyOperation(this.document, transformedOp);
    this.operations.set(this.version, transformedOp);
    this.version++;

    // 3. Update client state
    client.version = this.version;

    // 4. Broadcast to other clients (they'll transform it themselves)
    for (const [otherId, otherClient] of this.clients) {
      if (otherId !== clientId) {
        this.broadcast(otherId, transformedOp, this.version - 1);
      }
    }
  }

  // Get operations a client needs to catch up
  getOperationsSince(version: number): Operation[] {
    const ops: Operation[] = [];
    for (let v = version; v < this.version; v++) {
      const op = this.operations.get(v);
      if (op) ops.push(op);
    }
    return ops;
  }
}

The server acts as the single source of truth. When an operation arrives, the server transforms it against any operations that were applied after the client's last known version. This ensures the operation is correctly adjusted before being applied to the authoritative document.

Client-Side Integration

On the client side, we need to handle local edits optimistically, send them to the server, and apply remote operations as they arrive. The client maintains its own document copy and a version number:

// client.ts
import { applyOperation } from './apply';
import { transform } from './transform';
import { Operation } from './types';

export class CollaborationClient {
  private document: string;
  private version: number;
  private clientId: string;
  private sendToServer: (op: Operation, version: number) => void;
  private pendingLocalOps: Operation[];
  private bufferedRemoteOps: Operation[];

  constructor(
    clientId: string,
    initialDocument: string,
    initialVersion: number,
    sendFn: (op: Operation, version: number) => void
  ) {
    this.clientId = clientId;
    this.document = initialDocument;
    this.version = initialVersion;
    this.sendToServer = sendFn;
    this.pendingLocalOps = [];
    this.bufferedRemoteOps = [];
  }

  getDocument(): string {
    return this.document;
  }

  getVersion(): number {
    return this.version;
  }

  // Apply a local edit (optimistic)
  applyLocalOperation(op: Operation): void {
    // Apply locally immediately for responsiveness
    this.document = applyOperation(this.document, op);
    
    // Store pending operation
    this.pendingLocalOps.push(op);
    
    // Send to server with current version
    this.sendToServer(op, this.version);
    this.version++;
  }

  // Receive a remote operation from the server
  applyRemoteOperation(op: Operation, remoteVersion: number): void {
    // If we have pending local operations, transform the remote op
    let transformedRemote = op;
    
    if (this.pendingLocalOps.length > 0 && remoteVersion < this.version) {
      // Transform remote op against local ops that were applied after remoteVersion
      for (let i = remoteVersion; i < this.version && i - remoteVersion < this.pendingLocalOps.length; i++) {
        const localOp = this.pendingLocalOps[i - remoteVersion];
        if (localOp) {
          const result = transform(localOp, transformedRemote, 'B');
          transformedRemote = result.transformedB;
        }
      }
    }

    // Apply the transformed remote operation
    this.document = applyOperation(this.document, transformedRemote);
    this.version++;

    // Clean up pending operations that have been acknowledged
    // (In production, the server would send acknowledgments)
  }

  // Called when server acknowledges our operation
  acknowledgeOperation(originalVersion: number): void {
    // Remove acknowledged operations from pending list
    const idx = this.pendingLocalOps.findIndex((_, i) => 
      (this.version - this.pendingLocalOps.length + i) === originalVersion
    );
    if (idx >= 0) {
      this.pendingLocalOps.splice(idx, 1);
    }
  }

  // Sync with server state
  syncWithServer(document: string, version: number): void {
    this.document = document;
    this.version = version;
    this.pendingLocalOps = [];
    this.bufferedRemoteOps = [];
  }
}

The client applies local edits immediately for a responsive UI, then sends the operation to the server. When remote operations arrive, the client transforms them against any pending local operations to maintain consistency. This optimistic approach ensures the user never waits for network round-trips before seeing their own changes.

Putting It All Together: A Complete Example

Let's wire up the server and multiple clients in a simulated real-time collaboration session. This example runs entirely in memory but mirrors the flow of a production system:

// simulation.ts
import { CollaborationServer } from './server';
import { CollaborationClient } from './client';
import { Operation } from './types';

// Create server with initial document
const server = new CollaborationServer(
  "The quick brown fox",
  (clientId: string, op: Operation, version: number) => {
    // Simulate network delivery — push to client's incoming queue
    const client = clients.get(clientId);
    if (client) {
      // Schedule remote operation application (simulating async delivery)
      setTimeout(() => {
        client.applyRemoteOperation(op, version);
        console.log(`Client ${clientId} received remote op at version ${version}`);
        console.log(`  Document: "${client.getDocument()}"`);
      }, 50);
    }
  }
);

// Create two clients
const clients = new Map();

const alice = new CollaborationClient(
  'alice',
  server.getDocument(),
  server.getVersion(),
  (op: Operation, version: number) => {
    server.receiveOperation('alice', op, version);
  }
);

const bob = new CollaborationClient(
  'bob',
  server.getDocument(),
  server.getVersion(),
  (op: Operation, version: number) => {
    server.receiveOperation('bob', op, version);
  }
);

server.addClient('alice');
server.addClient('bob');
clients.set('alice', alice);
clients.set('bob', bob);

console.log('Initial document:', server.getDocument());

// Alice: changes "brown" to "lazy" (retain 10, delete 5, insert "lazy")
const aliceOp: Operation = [
  { type: 'retain', count: 10 },    // skip "The quick "
  { type: 'delete', count: 5 },     // remove "brown"
  { type: 'insert', chars: 'lazy' } // insert "lazy"
];
alice.applyLocalOperation(aliceOp);
console.log('Alice local:', alice.getDocument());

// Bob: simultaneously inserts "big " before "brown" (retain 10, insert "big ")
const bobOp: Operation = [
  { type: 'retain', count: 10 },    // skip "The quick "
  { type: 'insert', chars: 'big ' }  // insert "big "
];
bob.applyLocalOperation(bobOp);
console.log('Bob local:', bob.getDocument());

// Wait for operations to propagate
setTimeout(() => {
  console.log('\n--- Final State ---');
  console.log('Server document:', server.getDocument());
  console.log('Alice document:', alice.getDocument());
  console.log('Bob document:', bob.getDocument());
  
  // All should show "The quick big lazy fox"
  const allMatch = server.getDocument() === alice.getDocument() 
    && alice.getDocument() === bob.getDocument();
  console.log('All documents match:', allMatch);
}, 500);

When you run this simulation, all three documents converge to "The quick big lazy fox". Alice's change from "brown" to "lazy" and Bob's insertion of "big " are both preserved, despite being created concurrently from the same initial state.

Handling Edge Cases and Advanced Topics

Undo Support with Inverse Operations

To support undo in a collaborative environment, you need inverse operations—operations that reverse the effect of a previous operation. The challenge is that by the time a user hits undo, other operations may have transformed the original. The solution is to store the original operation, compute its inverse, and transform that inverse against any intervening operations:

// undo.ts
import { Operation, OpComponent } from './types';

export function invertOperation(op: Operation): Operation {
  const inverted: Operation = [];
  let cursor = 0;
  
  for (const component of op) {
    switch (component.type) {
      case 'retain':
        inverted.push({ type: 'retain', count: component.count });
        cursor += component.count;
        break;
      case 'insert':
        // Inverse of insert is delete
        inverted.push({ type: 'delete', count: component.chars.length });
        break;
      case 'delete':
        // Inverse of delete is... we need the original characters!
        // This requires storing the deleted content alongside the operation
        // For production, use a pair: { op, deletedContent }
        throw new Error('Cannot invert delete without original content');
    }
  }
  return inverted;
}

// In production, store operations as:
interface StoredOperation {
  operation: Operation;
  originalContent?: string; // characters deleted, for undo support
}

A complete undo implementation requires tracking deleted content so it can be reinserted. This is why production OT systems store operations with metadata about what was removed.

Compressing Operations

Real-world typing generates many small operations (one per character). To reduce network traffic, operations should be compressed. Adjacent inserts can be merged, and retain-delete sequences can be simplified:

// compress.ts
import { Operation } from './types';

export function compressOperations(ops: Operation[]): Operation {
  const merged: Operation = [];
  
  for (const op of ops) {
    for (const component of op) {
      const last = merged.length > 0 ? merged[merged.length - 1] : null;
      
      if (component.type === 'insert' && last && last.type === 'insert') {
        // Merge adjacent inserts
        last.chars += component.chars;
      } else if (component.type === 'delete' && last && last.type === 'delete') {
        // Merge adjacent deletes
        last.count += component.count;
      } else if (component.type === 'retain' && last && last.type === 'retain') {
        // Merge adjacent retains
        last.count += component.count;
      } else {
        merged.push({ ...component });
      }
    }
  }
  
  return merged;
}

Version Vector for Distributed Systems

In a fully peer-to-peer system without a central server, simple version numbers aren't sufficient. Instead, use version vectors that track each peer's logical clock. Each peer increments its own entry in the vector when it creates an operation. When receiving an operation, a peer can determine exactly which operations it needs to transform against by comparing version vectors:

// version-vector.ts
export type VersionVector = Map;

export function incrementVector(vv: VersionVector, peerId: string): VersionVector {
  const newVv = new Map(vv);
  newVv.set(peerId, (newVv.get(peerId) || 0) + 1);
  return newVv;
}

export function compareVectors(
  local: VersionVector,
  remote: VersionVector
): 'concurrent' | 'ahead' | 'behind' | 'equal' {
  let localAhead = false;
  let remoteAhead = false;
  
  for (const [peerId, count] of local) {
    const remoteCount = remote.get(peerId) || 0;
    if (count > remoteCount) localAhead = true;
    if (count < remoteCount) remoteAhead = true;
  }
  for (const [peerId, count] of remote) {
    if (!local.has(peerId) && count > 0) remoteAhead = true;
  }
  
  if (localAhead && remoteAhead) return 'concurrent';
  if (localAhead) return 'ahead';
  if (remoteAhead) return 'behind';
  return 'equal';
}

Version vectors allow each peer to independently determine causality—whether one operation happened before another, or if they're concurrent and need transformation.

Best Practices for Production OT Systems

1. Use a Proven Library for the Core Algorithm

Implementing OT from scratch is an excellent learning exercise, but for production, use battle-tested libraries like share.js, ot.js, or the operational transform implementation inside Quill (via @teamwork/sharedb). These libraries have handled thousands of edge cases over years of real-world use.

2. Implement Strict Operation Ordering

Operations must be applied in causal order. If operation A causally precedes operation B (the user saw A's effects before creating B), the system must preserve that order. Use version vectors or Lamport timestamps to enforce causal ordering.

3. Handle Disconnection Gracefully

Clients will

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles