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:
- Automatic conflict resolution — Users never see merge conflicts; edits are transformed transparently
- Low latency — Operations are applied locally first, then synchronized, giving instant feedback
- Intent preservation — A user's intended change is preserved even when other edits occur concurrently
- Proven scalability — OT has been deployed in production for decades across millions of users
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:
- Retain — Advance the cursor without modifying content
- Insert — Add new characters at the current cursor position
- Delete — Remove characters at the current cursor position
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:
- Document store — Holds the current document text and a version number
- Operation log — Records all applied operations for transformation context
- Client registry — Tracks connected clients and their synchronized versions
// 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