Skip to main content

Command Palette

Search for a command to run...

Real-time Collaboration OT CRDT

Published
8 min readView as Markdown
T

Welcome to TopperBlog! 👋

I'm a tech content creator passionate about helping developers level up their careers and master cutting-edge technologies.

🎯 What I Write About: • AI/ML Engineering & LLMs • Web3 & Blockchain Development
• System Design & Architecture • Interview Preparation (FAANG) • Freelancing & Remote Work • Modern Tech Stacks (Next.js, React, Rust, TypeScript) • Performance Optimization & Best Practices

💼 Mission: Sharing practical, actionable insights that accelerate your tech career and maximize your earning potential.

📚 15+ In-Depth Guides covering everything from earning $10k/month as a freelancer to cracking FAANG interviews.

🌐 Let's connect and grow together in this amazing tech journey!

#TechBlogger #SoftwareEngineering #CareerGrowth #WebDevelopment #AIEngineering

Real-Time Collaboration: Operational Transformation vs CRDTs for Modern Applications

Metadata

SEO Title: Real-Time Collaboration: OT vs CRDT Implementation Guide 2026

Meta Description: Learn how to implement real-time collaboration using Operational Transformation and CRDTs. Compare approaches, explore TypeScript solutions, and avoid common pitfalls in distributed systems.

Keywords: operational transformation, CRDT, real-time collaboration, conflict resolution, distributed systems, TypeScript collaboration, OT vs CRDT, collaborative editing

Tags: real-time-collaboration, operational-transformation, crdt, distributed-systems, typescript, conflict-resolution, collaborative-editing


The Problem: Building Real-Time Collaboration in 2026

Real-time collaboration has evolved from a nice-to-have feature to a fundamental expectation in modern applications. Whether you're building a document editor, design tool, project management platform, or collaborative whiteboard, users expect Google Docs-level synchronization where multiple people can edit simultaneously without conflicts or data loss.

The core challenge lies in distributed state synchronization. When multiple users edit the same document concurrently, their changes must be merged in a way that:

  1. Preserves user intent - If User A deletes character 5 while User B inserts at position 3, both operations should apply correctly
  2. Maintains consistency - All clients must eventually converge to the same state
  3. Handles network partitions - Users should work offline and sync when reconnected
  4. Scales efficiently - The system must handle hundreds or thousands of concurrent users
  5. Provides low latency - Changes should appear instantaneous, typically under 100ms

Traditional approaches like pessimistic locking (where one user locks the document while editing) create terrible user experiences. Optimistic approaches that simply "last write wins" lose data and frustrate users. This is where Operational Transformation (OT) and Conflict-free Replicated Data Types (CRDTs) come in.

Operational Transformation: The Classic Approach

OT, pioneered by Google Wave and used in Google Docs, transforms operations based on concurrent changes. When two operations happen simultaneously, OT algorithms transform them so they can be applied in any order while maintaining consistency.

Key characteristics:

  • Requires a central server to determine operation order
  • Operations must be transformed against all concurrent operations
  • Complex to implement correctly (the infamous TP2 puzzle)
  • Excellent for linear data structures like text

CRDTs: The Modern Alternative

CRDTs are data structures that guarantee eventual consistency without coordination. They're mathematically proven to converge, making them attractive for peer-to-peer systems and offline-first applications.

Key characteristics:

  • No central authority required (though often used with servers)
  • Operations are commutative, associative, and idempotent
  • Simpler correctness guarantees
  • Can have higher memory overhead
  • Better suited for distributed, offline-first scenarios

Modern TypeScript Solution

Let's implement both approaches for a collaborative text editor, starting with the simpler CRDT approach using Yjs, then comparing with an OT implementation.

CRDT Implementation with Yjs

import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import { MonacoBinding } from 'y-monaco';
import * as monaco from 'monaco-editor';

class CollaborativeEditor {
  private ydoc: Y.Doc;
  private provider: WebsocketProvider;
  private ytext: Y.Text;
  private editor: monaco.editor.IStandaloneCodeEditor;

  constructor(
    roomId: string,
    editorElement: HTMLElement,
    wsUrl: string = 'ws://localhost:1234'
  ) {
    // Initialize Yjs document
    this.ydoc = new Y.Doc();
    this.ytext = this.ydoc.getText('monaco');

    // Setup WebSocket provider for synchronization
    this.provider = new WebsocketProvider(wsUrl, roomId, this.ydoc, {
      connect: true,
      // Awareness for cursor positions and user presence
      awareness: {
        user: {
          name: this.getUserName(),
          color: this.getUserColor(),
        }
      }
    });

    // Initialize Monaco editor
    this.editor = monaco.editor.create(editorElement, {
      value: '',
      language: 'typescript',
      theme: 'vs-dark',
    });

    // Bind Yjs text to Monaco editor
    new MonacoBinding(
      this.ytext,
      this.editor.getModel()!,
      new Set([this.editor]),
      this.provider.awareness
    );

    this.setupEventHandlers();
  }

  private setupEventHandlers(): void {
    // Handle connection status
    this.provider.on('status', (event: { status: string }) => {
      console.log(`Connection status: ${event.status}`);
      this.updateConnectionUI(event.status);
    });

    // Handle sync status
    this.provider.on('sync', (isSynced: boolean) => {
      if (isSynced) {
        console.log('Document synced');
      }
    });

    // Observe remote changes for analytics
    this.ytext.observe((event) => {
      event.changes.delta.forEach((change) => {
        if (change.insert) {
          this.trackChange('insert', change.insert);
        } else if (change.delete) {
          this.trackChange('delete', change.delete);
        }
      });
    });
  }

  // Offline support with IndexedDB persistence
  async enableOfflineSupport(): Promise<void> {
    const { IndexeddbPersistence } = await import('y-indexeddb');

    new IndexeddbPersistence(this.ydoc.guid, this.ydoc);
    console.log('Offline support enabled');
  }

  // Undo/Redo support
  setupUndoManager(): Y.UndoManager {
    const undoManager = new Y.UndoManager(this.ytext, {
      trackedOrigins: new Set([this.ydoc.clientID])
    });

    // Bind to editor commands
    this.editor.addCommand(
      monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyZ,
      () => undoManager.undo()
    );

    this.editor.addCommand(
      monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KeyZ,
      () => undoManager.redo()
    );

    return undoManager;
  }

  private getUserName(): string {
    return localStorage.getItem('userName') || 'Anonymous';
  }

  private getUserColor(): string {
    return `#${Math.floor(Math.random()*16777215).toString(16)}`;
  }

  private updateConnectionUI(status: string): void {
    // Update UI based on connection status
  }

  private trackChange(type: string, content: any): void {
    // Analytics tracking
  }

  destroy(): void {
    this.provider.destroy();
    this.ydoc.destroy();
    this.editor.dispose();
  }
}

// Usage
const editor = new CollaborativeEditor(
  'document-123',
  document.getElementById('editor')!
);

await editor.enableOfflineSupport();
editor.setupUndoManager();

OT Implementation with ShareDB

import ShareDB from 'sharedb/lib/client';
import { Connection } from 'sharedb/lib/client';
import richText from 'rich-text';
import * as monaco from 'monaco-editor';

ShareDB.types.register(richText.type);

class OTCollaborativeEditor {
  private connection: Connection;
  private doc: ShareDB.Doc;
  private editor: monaco.editor.IStandaloneCodeEditor;
  private suppressRemoteChanges = false;

  constructor(
    docId: string,
    editorElement: HTMLElement,
    wsUrl: string = 'ws://localhost:8080'
  ) {
    const socket = new WebSocket(wsUrl);
    this.connection = new ShareDB.Connection(socket);

    this.doc = this.connection.get('documents', docId);

    this.editor = monaco.editor.create(editorElement, {
      value: '',
      language: 'typescript',
      theme: 'vs-dark',
    });

    this.initialize();
  }

  private async initialize(): Promise<void> {
    await this.subscribeToDocument();
    this.setupLocalChangeHandler();
    this.setupRemoteChangeHandler();
  }

  private subscribeToDocument(): Promise<void> {
    return new Promise((resolve, reject) => {
      this.doc.subscribe((err) => {
        if (err) {
          reject(err);
          return;
        }

        if (this.doc.type === null) {
          // Create document if it doesn't exist
          this.doc.create([], 'rich-text', (err) => {
            if (err) reject(err);
            else resolve();
          });
        } else {
          // Set initial content
          this.suppressRemoteChanges = true;
          this.editor.setValue(this.doc.data.ops?.[0]?.insert || '');
          this.suppressRemoteChanges = false;
          resolve();
        }
      });
    });
  }

  private setupLocalChangeHandler(): void {
    const model = this.editor.getModel()!;

    model.onDidChangeContent((event) => {
      if (this.suppressRemoteChanges) return;

      event.changes.forEach((change) => {
        const op = this.createOperation(change);

        this.doc.submitOp(op, (err) => {
          if (err) {
            console.error('Failed to submit operation:', err);
            // Handle conflict resolution
            this.handleConflict();
          }
        });
      });
    });
  }

  private setupRemoteChangeHandler(): void {
    this.doc.on('op', (op, source) => {
      if (source) return; // Ignore own operations

      this.suppressRemoteChanges = true;
      this.applyRemoteOperation(op);
      this.suppressRemoteChanges = false;
    });
  }

  private createOperation(
    change: monaco.editor.IModelContentChange
  ): any[] {
    const ops = [];

    if (change.rangeLength > 0) {
      // Delete operation
      ops.push({
        p: [change.rangeOffset],
        d: change.text
      });
    }

    if (change.text.length > 0) {
      // Insert operation
      ops.push({
        p: [change.rangeOffset],
        i: change.text
      });
    }

    return ops;
  }

  private applyRemoteOperation(op: any[]): void {
    const model = this.editor.getModel()!;

    op.forEach((component) => {
      if (component.i) {
        // Insert
        const position = model.getPositionAt(component.p[0]);
        model.applyEdits([{
          range: new monaco.Range(
            position.lineNumber,
            position.column,
            position.lineNumber,
            position.column
          ),
          text: component.i
        }]);
      } else if (component.d) {
        // Delete
        const startPos = model.getPositionAt(component.p[0]);
        const endPos = model.getPositionAt(
          component.p[0] + component.d.length
        );
        model.applyEdits([{
          range: new monaco.Range(
            startPos.lineNumber,
            startPos.column,
            endPos.lineNumber,
            endPos.column
          ),
          text: ''
        }]);
      }
    });
  }

  private handleConflict(): void {
    // Reload document state on conflict
    this.suppressRemoteChanges = true;
    this.editor.setValue(this.doc.data.ops?.[0]?.insert || '');
    this.suppressRemoteChanges = false;
  }

  destroy(): void {
    this.doc.destroy();
    this.connection.close();
    this.editor.dispose();
  }
}

Common Pitfalls and How to Avoid Them

1. Ignoring Tombstones in CRDTs

CRDTs often use tombstones to track deleted items, which can cause memory bloat.

Solution: Implement garbage collection for old tombstones:

// Periodically clean up old tombstones
setInterval(() => {
  const threshold = Date.now() - (7 * 24 * 60 * 60 * 1000); // 7 days
  ydoc.gc(threshold);
}, 24 * 60 * 60 * 1000);

2. Not Handling Network Partitions

Users expect to work offline and sync later.

Solution: Always implement local persistence and conflict resolution strategies.

3. Poor Cursor/Selection Synchronization

Showing where other users are editing is crucial for UX.

Solution: Use awareness protocols (built into Yjs) or implement custom presence:

provider.awareness.setLocalStateField('cursor', {
  position: editor.getPosition(),
  selection: editor.getSelection()
});

4. Inadequate Testing of Concurrent Operations

Race conditions are hard to reproduce.

Solution: Use property-based testing with tools like fast-check:

import fc from 'fast-check';

fc.assert(
  fc.property(
    fc.array(fc.record({
      type: fc.constantFrom('insert', 'delete'),
      position: fc.nat(1000),
      content: fc.string()
    })),
    (operations) => {
      // Apply operations in different orders
      // Verify convergence
    }
  )
);

Best Practices

  1. Choose based on architecture: Use CRDTs for peer-to-peer or offline-first apps; OT for centralized systems with strong consistency requirements

  2. Implement exponential backoff: For reconnection attempts to avoid overwhelming servers

  3. Use delta synchronization: Only send changes, not entire documents

  4. Implement proper authorization: Verify permissions server-side for all operations

  5. Monitor performance: Track operation size, sync latency, and memory usage

  6. Version your protocol: Allow graceful upgrades as your collaboration protocol evolves

Frequently Asked Questions

Q: Should I use OT or CRDTs for my application?

A: Use CRDTs if you need offline-first capabilities, peer-to-peer sync, or want simpler correctness guarantees. Use OT if you have a centralized architecture and need fine-grained control over operation ordering. For most modern applications in 2026, CRDTs (specifically Yjs) offer the best balance of features and simplicity.

Q: How do I handle large documents efficiently?

A: Implement chunking or pagination. Load only visible portions of the document and use virtual scrolling. Yjs supports subdocuments for this purpose. Consider splitting very large documents into smaller collaborative units.

Q: What about security and access control?

A: Always validate operations server-side. Use JWT tokens for authentication and implement row-level security in your database. Never trust client-side validation alone. Encrypt sensitive data in transit and at rest.

Q: How do I handle schema migrations in collaborative documents?

A: Version your document structure and implement migration functions. When loading a document, check its version and apply necessary migrations before allowing edits. Store the schema version in the document metadata.

Q: Can I use CRDTs with relational databases?

A: Yes, but it requires careful design. Store CRDT state as JSON/JSONB columns or use specialized CRDT databases like AntidoteDB. PostgreSQL with JSONB support works well for moderate-scale applications.

Q: How do I debug synchronization issues?

A: Implement comprehensive logging of all operations with timestamps and client IDs. Use tools like Yjs's built-in debugging features. Create reproducible test cases by recording and replaying operation sequences.

Q: What's the performance overhead of real-time collaboration?

A: Expect 10-30% memory overhead for CRDT metadata and 50-200ms latency for cross-region synchronization. Local operations should remain under 16ms. Profile regularly and optimize hot paths. Consider using WebRTC for peer-to-peer connections to reduce server load.


Real-time collaboration is complex but achievable with the right tools and understanding. Both OT and CRDTs have their place in modern applications, and frameworks like Yjs and ShareDB handle much of the complexity for you. Focus on user experience, handle edge cases gracefully, and test thoroughly with concurrent operations.