Front-end Engineering Lab

Collaborative Editing

Build real-time collaborative editing like Google Docs with Operational Transformation or CRDTs

Enable multiple users to edit the same document simultaneously. This guide covers conflict-free editing patterns.

🎯 The Challenge

Problem: Two users edit same text

User A: "Hello"
User B: "Hello"

User A types: "Hello World"
User B types: "Hello everyone"

Without coordination:
❌ Final result: "Hello Worldyone" (conflict!)

With coordination:
✅ Final result: "Hello World everyone" or proper merge

🔄 Pattern 1: Operational Transformation (OT)

Transform operations to resolve conflicts.

type Operation = 
  | { type: 'insert'; position: number; text: string }
  | { type: 'delete'; position: number; length: number };

class OperationalTransform {
  // Transform operation A against operation B
  static transform(opA: Operation, opB: Operation): Operation {
    if (opA.type === 'insert' && opB.type === 'insert') {
      if (opA.position <= opB.position) {
        return opA;
      } else {
        return {
          ...opA,
          position: opA.position + opB.text.length
        };
      }
    }
    
    if (opA.type === 'insert' && opB.type === 'delete') {
      if (opA.position <= opB.position) {
        return opA;
      } else if (opA.position >= opB.position + opB.length) {
        return {
          ...opA,
          position: opA.position - opB.length
        };
      } else {
        return {
          ...opA,
          position: opB.position
        };
      }
    }
    
    if (opA.type === 'delete' && opB.type === 'insert') {
      if (opB.position <= opA.position) {
        return {
          ...opA,
          position: opA.position + opB.text.length
        };
      } else if (opB.position >= opA.position + opA.length) {
        return opA;
      } else {
        return {
          ...opA,
          length: opA.length + opB.text.length
        };
      }
    }
    
    if (opA.type === 'delete' && opB.type === 'delete') {
      if (opA.position <= opB.position) {
        if (opA.position + opA.length <= opB.position) {
          return opA;
        } else {
          return {
            ...opA,
            length: Math.min(opA.length, opB.position - opA.position)
          };
        }
      } else {
        if (opB.position + opB.length <= opA.position) {
          return {
            ...opA,
            position: opA.position - opB.length
          };
        } else {
          return {
            ...opA,
            position: opB.position,
            length: Math.max(0, opA.position + opA.length - (opB.position + opB.length))
          };
        }
      }
    }
    
    return opA;
  }
  
  // Apply operation to text
  static apply(text: string, op: Operation): string {
    if (op.type === 'insert') {
      return (
        text.slice(0, op.position) +
        op.text +
        text.slice(op.position)
      );
    } else {
      return (
        text.slice(0, op.position) +
        text.slice(op.position + op.length)
      );
    }
  }
}

// Usage
const text = "Hello";

const opA: Operation = { type: 'insert', position: 5, text: ' World' };
const opB: Operation = { type: 'insert', position: 5, text: ' everyone' };

// Transform B against A
const transformedOpB = OperationalTransform.transform(opB, opA);

// Apply operations
let result = OperationalTransform.apply(text, opA);
result = OperationalTransform.apply(result, transformedOpB);

console.log(result); // "Hello World everyone"

📝 Pattern 2: Collaborative Text Editor

Real-time editor with OT.

class CollaborativeEditor {
  private ws: WebSocket;
  private localVersion = 0;
  private serverVersion = 0;
  private pendingOps: Operation[] = [];
  private buffer: Operation[] = [];
  
  constructor(
    private documentId: string,
    private userId: string,
    private onTextChange: (text: string) => void
  ) {
    this.ws = new WebSocket(`wss://api.example.com/collab/${documentId}`);
    this.setupWebSocket();
  }
  
  private setupWebSocket() {
    this.ws.onopen = () => {
      this.ws.send(JSON.stringify({
        type: 'join',
        documentId: this.documentId,
        userId: this.userId
      }));
    };
    
    this.ws.onmessage = (event) => {
      const message = JSON.parse(event.data);
      this.handleMessage(message);
    };
  }
  
  // Local edit
  applyLocalOperation(op: Operation) {
    this.localVersion++;
    this.pendingOps.push(op);
    
    // Send to server
    this.ws.send(JSON.stringify({
      type: 'operation',
      operation: op,
      version: this.serverVersion
    }));
  }
  
  // Receive remote operation
  private handleMessage(message: any) {
    switch (message.type) {
      case 'operation':
        this.handleRemoteOperation(message.operation, message.userId);
        break;
      case 'ack':
        this.handleAck(message.version);
        break;
    }
  }
  
  private handleRemoteOperation(op: Operation, userId: string) {
    if (userId === this.userId) {
      return; // Ignore own operations
    }
    
    // Transform against pending operations
    let transformedOp = op;
    for (const pendingOp of this.pendingOps) {
      transformedOp = OperationalTransform.transform(transformedOp, pendingOp);
    }
    
    // Apply to local state
    this.buffer.push(transformedOp);
    this.applyBuffer();
    
    this.serverVersion++;
  }
  
  private handleAck(version: number) {
    // Remove acknowledged operation from pending
    if (this.pendingOps.length > 0) {
      this.pendingOps.shift();
      this.serverVersion = version;
    }
  }
  
  private applyBuffer() {
    while (this.buffer.length > 0) {
      const op = this.buffer.shift()!;
      // Apply operation to editor
      // (implementation depends on your text editor)
    }
  }
}

React Component

import { useEffect, useRef, useState } from 'react';

function CollaborativeTextEditor({ documentId }: { documentId: string }) {
  const { user } = useAuth();
  const [text, setText] = useState('');
  const editorRef = useRef<CollaborativeEditor>();
  const textareaRef = useRef<HTMLTextAreaElement>(null);
  
  useEffect(() => {
    const editor = new CollaborativeEditor(
      documentId,
      user.id,
      (newText) => setText(newText)
    );
    
    editorRef.current = editor;
    
    return () => editor.close();
  }, [documentId, user.id]);
  
  const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
    const newText = e.target.value;
    const oldText = text;
    
    // Calculate operation
    const op = calculateOperation(oldText, newText, e.target.selectionStart);
    
    if (op) {
      editorRef.current?.applyLocalOperation(op);
      setText(newText);
    }
  };
  
  return (
    <div className="collaborative-editor">
      <textarea
        ref={textareaRef}
        value={text}
        onChange={handleChange}
        placeholder="Start typing..."
      />
      
      <Cursors documentId={documentId} />
    </div>
  );
}

function calculateOperation(
  oldText: string,
  newText: string,
  cursorPosition: number
): Operation | null {
  if (newText.length > oldText.length) {
    // Insert
    const insertPosition = cursorPosition - (newText.length - oldText.length);
    return {
      type: 'insert',
      position: insertPosition,
      text: newText.slice(insertPosition, cursorPosition)
    };
  } else if (newText.length < oldText.length) {
    // Delete
    return {
      type: 'delete',
      position: cursorPosition,
      length: oldText.length - newText.length
    };
  }
  
  return null;
}

🎨 Pattern 3: Remote Cursors

Show other users' cursors and selections.

interface Cursor {
  userId: string;
  userName: string;
  color: string;
  position: number;
  selection?: { start: number; end: number };
}

class CursorManager {
  private cursors = new Map<string, Cursor>();
  private ws: WebSocket;
  
  constructor(private documentId: string, private userId: string, ws: WebSocket) {
    this.ws = ws;
  }
  
  updateLocalCursor(position: number, selection?: { start: number; end: number }) {
    this.ws.send(JSON.stringify({
      type: 'cursor',
      documentId: this.documentId,
      userId: this.userId,
      position,
      selection
    }));
  }
  
  updateRemoteCursor(cursor: Cursor) {
    if (cursor.userId !== this.userId) {
      this.cursors.set(cursor.userId, cursor);
      this.notifyListeners();
    }
  }
  
  removeCursor(userId: string) {
    this.cursors.delete(userId);
    this.notifyListeners();
  }
  
  getCursors(): Cursor[] {
    return Array.from(this.cursors.values());
  }
  
  private notifyListeners() {
    window.dispatchEvent(new CustomEvent('cursors-change', {
      detail: { cursors: this.getCursors() }
    }));
  }
}

// React Component
function Cursors({ documentId }: { documentId: string }) {
  const [cursors, setCursors] = useState<Cursor[]>([]);
  
  useEffect(() => {
    const handler = (event: CustomEvent) => {
      setCursors(event.detail.cursors);
    };
    
    window.addEventListener('cursors-change', handler as EventListener);
    
    return () => {
      window.removeEventListener('cursors-change', handler as EventListener);
    };
  }, []);
  
  return (
    <>
      {cursors.map(cursor => (
        <div
          key={cursor.userId}
          className="remote-cursor"
          style={{
            position: 'absolute',
            left: calculateCursorPosition(cursor.position).x,
            top: calculateCursorPosition(cursor.position).y,
            borderLeft: `2px solid ${cursor.color}`,
            height: '1em'
          }}
        >
          <span
            style={{
              backgroundColor: cursor.color,
              color: 'white',
              padding: '2px 4px',
              borderRadius: '3px',
              fontSize: '11px',
              whiteSpace: 'nowrap'
            }}
          >
            {cursor.userName}
          </span>
        </div>
      ))}
    </>
  );
}

function calculateCursorPosition(position: number): { x: number; y: number } {
  // Calculate cursor position based on text position
  // Implementation depends on your editor
  return { x: 0, y: 0 };
}

🔗 Pattern 4: CRDT (Conflict-Free Replicated Data Type)

Alternative to OT, simpler conflict resolution.

// Simple CRDT implementation (YATA)
interface Character {
  id: string;
  value: string;
  left: string | null; // ID of character to the left
  deleted: boolean;
}

class CRDTEditor {
  private chars: Map<string, Character> = new Map();
  private ws: WebSocket;
  
  constructor(private userId: string) {
    this.ws = new WebSocket('wss://api.example.com/crdt');
  }
  
  insert(position: number, value: string) {
    const id = `${this.userId}-${Date.now()}-${Math.random()}`;
    const leftId = this.getCharIdAtPosition(position - 1);
    
    const char: Character = {
      id,
      value,
      left: leftId,
      deleted: false
    };
    
    this.chars.set(id, char);
    
    // Broadcast to other users
    this.ws.send(JSON.stringify({
      type: 'insert',
      char
    }));
    
    return this.getText();
  }
  
  delete(position: number) {
    const charId = this.getCharIdAtPosition(position);
    const char = this.chars.get(charId);
    
    if (char) {
      char.deleted = true;
      
      // Broadcast to other users
      this.ws.send(JSON.stringify({
        type: 'delete',
        charId
      }));
    }
    
    return this.getText();
  }
  
  private getCharIdAtPosition(position: number): string | null {
    const chars = this.getOrderedChars();
    return chars[position]?.id || null;
  }
  
  private getOrderedChars(): Character[] {
    // Sort characters based on their 'left' references
    const ordered: Character[] = [];
    const chars = Array.from(this.chars.values()).filter(c => !c.deleted);
    
    // Find first character (left = null)
    let current = chars.find(c => c.left === null);
    
    while (current) {
      ordered.push(current);
      current = chars.find(c => c.left === current!.id);
    }
    
    return ordered;
  }
  
  getText(): string {
    return this.getOrderedChars()
      .map(c => c.value)
      .join('');
  }
  
  applyRemoteOperation(op: any) {
    if (op.type === 'insert') {
      this.chars.set(op.char.id, op.char);
    } else if (op.type === 'delete') {
      const char = this.chars.get(op.charId);
      if (char) {
        char.deleted = true;
      }
    }
  }
}

📚 Comparison: OT vs CRDT

AspectOTCRDT
ComplexityHighMedium
Conflict ResolutionTransform operationsMerge states
Undo/RedoComplexSimple
Offline SupportHarderEasier
PerformanceFastSlower (more data)
Use CaseReal-time collabOffline-first

🏢 Real-World Examples

Google Docs

// Uses custom OT implementation
// Server is source of truth
// Client transforms against server ops

Figma

// CRDT-based
// Optimized for design tools
// Vector graphics sync

Notion

// Block-based CRDT
// Each block is independent
// Easy undo/redo

📚 Key Takeaways

  1. OT for real-time - Google Docs style
  2. CRDT for offline-first - Better for mobile
  3. Show remote cursors - Essential UX
  4. Handle conflicts - Don't lose data
  5. Test with latency - Simulate slow networks
  6. Undo/Redo - Harder than it looks
  7. Consider libraries - Yjs, Automerge, ShareDB

Collaborative editing is complex. Use a library (Yjs recommended) unless you have specific needs.

On this page