---
title: "System Design #9: Design a Collaborative Text Editor"
description: ""
canonical_url: https://devprep.co/articles/system-design-collaborative-text-editor
md_url: https://devprep.co/articles/system-design-collaborative-text-editor.md
last_updated: 2026-02-10
access: public
---

# System Design #9: Design a Collaborative Text Editor

## Hey folks, Rahul here 👋

Google Docs, Notion, Figma — real-time collaboration is the feature that separates toys from tools. And it's arguably the hardest frontend system design problem you'll encounter.

The core question is deceptively simple: _"Two people type at the same time. How do both documents converge to the same state?"_ The answer involves either Operational Transformation (OT) or Conflict-free Replicated Data Types (CRDTs), and most candidates can't explain either. Let's fix that.

## R — Requirements

### Functional Requirements

- Rich text editing (bold, italic, headings, lists, links, images)
- Real-time collaboration: multiple users editing simultaneously
- Cursor presence: see other users' cursor positions and selections
- Conflict resolution: concurrent edits converge to the same state
- Version history: browse and restore previous versions
- Offline editing with sync on reconnect
- Comments and suggestions (track changes)

### Non-Functional Requirements

- **Latency**: Local edits must feel instant (<16ms)
- **Convergence**: All clients must reach the same document state
- **Scalability**: Support 50+ simultaneous editors
- **Reliability**: No data loss on crashes or network failures
- **Performance**: Handle documents with 100K+ characters

## A — Architecture

### OT vs CRDT: The Core Decision

#### Operational Transformation (OT) — Google Docs' Approach

OT transforms operations against each other so they can be applied in any order and produce the same result.

```typescript
// Two users type simultaneously:
// User A: insert "X" at position 5
// User B: insert "Y" at position 3

// Without transformation:
// A sees: "helloXworld" → insert Y at 3 → "helYloXworld" ✗
// B sees: "helYloworld" → insert X at 5 → "helYlXoworld" ✗
// Different results!

// With OT:
// Transform A's op against B's: A inserted after B's position, so shift A right
// A' = insert "X" at position 6 (5 + 1)
// Now both converge: "helYloXworld" ✓

interface Operation {
  type: 'insert' | 'delete' | 'retain';
  position: number;
  content?: string;  // For insert
  count?: number;    // For delete/retain
  userId: string;
  revision: number;  // Server revision at time of creation
}
```

#### CRDT — Figma/Yjs Approach ✅ (Recommended for new projects)

CRDTs assign unique IDs to every character, making merge commutative by design — no transformation needed:

```typescript
// Each character has a unique, ordered ID
interface CRDTChar {
  id: { clock: number; clientId: string }; // Lamport timestamp
  value: string;
  parent: CRDTChar['id'] | null;           // Left neighbor at insertion time
  isDeleted: boolean;                        // Tombstone (never truly removed)
}

// Insertion: place between two existing characters
// The ID ordering guarantees deterministic merge
// Even if two users insert at the "same" position,
// the client IDs break the tie deterministically
```

**Why CRDT for new projects?**

- No server-side transformation logic (simpler server)
- Works offline natively (merge when reconnected)
- Libraries like **Yjs** handle the complexity
- Google chose OT in 2006; if they started today, they'd likely use CRDTs

### Architecture with Yjs

```text
Client A                  Server (y-websocket)           Client B
┌─────────┐               ┌──────────────┐              ┌─────────┐
│ TipTap  │───updates───→ │  Broadcast   │ ←──updates──│ TipTap  │
│ Editor  │←──updates───  │  + Persist   │  ──updates→──│ Editor  │
│ + Yjs   │               │  (y-leveldb) │              │ + Yjs   │
│ binding │               └──────────────┘              │ binding │
└─────────┘                                             └─────────┘
     │                                                       │
     └── Local Yjs Doc (IndexedDB)                           └── Local Yjs Doc
```

## D — Data Model

### Document State

```typescript
interface CollaborativeDoc {
  id: string;
  title: string;

  // Yjs state
  ydoc: Y.Doc;              // The CRDT document
  content: Y.XmlFragment;   // Rich text content (maps to TipTap)

  // Awareness (ephemeral state — not persisted)
  awareness: {
    [clientId: string]: {
      user: { name: string; color: string; avatar: string };
      cursor: { anchor: number; head: number } | null;
      isTyping: boolean;
      lastActive: number;
    };
  };

  // Metadata (stored in regular DB, not CRDT)
  metadata: {
    created_at: string;
    updated_at: string;
    owner_id: string;
    collaborators: { user_id: string; permission: 'view' | 'edit' | 'comment' }[];
    word_count: number;
  };
}

// Version history entry
interface DocVersion {
  id: string;
  doc_id: string;
  snapshot: Uint8Array;    // Yjs encoded state vector
  created_at: string;
  created_by: string;
  title: string;           // Auto: "March 15, 2:30 PM" or user-named
}
```

### Awareness Protocol (Cursor Presence)

```typescript
import { Awareness } from 'y-protocols/awareness';

function setupAwareness(doc: Y.Doc, provider: WebsocketProvider) {
  const awareness = provider.awareness;

  // Set local user info
  awareness.setLocalState({
    user: {
      name: currentUser.name,
      color: getCollaboratorColor(currentUser.id),
      avatar: currentUser.avatar_url,
    },
    cursor: null,
  });

  // Update cursor position on selection change
  editor.on('selectionUpdate', ({ editor }) => {
    const { from, to } = editor.state.selection;
    awareness.setLocalStateField('cursor', {
      anchor: from,
      head: to,
    });
  });

  // Listen for remote cursor changes
  awareness.on('change', () => {
    const states = awareness.getStates();
    updateRemoteCursors(states);
  });

  // Clean up stale cursors (user closed tab without proper disconnect)
  awareness.on('change', ({ removed }) => {
    removed.forEach(clientId => removeRemoteCursor(clientId));
  });
}
```

## I — Interface Definition

### WebSocket Protocol

```typescript
// y-websocket handles binary Yjs sync messages automatically
// Custom messages for application-level features:

type WSMessage =
  | { type: 'sync'; data: Uint8Array }          // Yjs sync protocol
  | { type: 'awareness'; data: Uint8Array }     // Cursor/presence
  | { type: 'save-version'; title?: string }    // Snapshot current state
  | { type: 'restore-version'; versionId: string }
  | { type: 'comment-add'; comment: CommentData }
  | { type: 'permission-change'; userId: string; permission: string };
```

### TipTap + Yjs Integration

```typescript
import { useEditor } from '@tiptap/react';
import Collaboration from '@tiptap/extension-collaboration';
import CollaborationCursor from '@tiptap/extension-collaboration-cursor';
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';

function CollaborativeEditor({ docId }: { docId: string }) {
  const ydoc = useMemo(() => new Y.Doc(), []);

  const provider = useMemo(() =>
    new WebsocketProvider(WS_URL, docId, ydoc, {
      connect: true,
      // Reconnect with exponential backoff
      maxBackoffTime: 10000,
    }),
    [docId, ydoc]
  );

  const editor = useEditor({
    extensions: [
      StarterKit.configure({ history: false }), // Disable default history!
      Collaboration.configure({ document: ydoc }),
      CollaborationCursor.configure({
        provider,
        user: { name: currentUser.name, color: userColor },
      }),
      // ... other extensions
    ],
  });

  // Connection status indicator
  const [status, setStatus] = useState<'connected' | 'connecting' | 'disconnected'>('connecting');

  useEffect(() => {
    provider.on('status', ({ status }: { status: string }) => {
      setStatus(status as any);
    });

    return () => {
      provider.destroy();
      ydoc.destroy();
    };
  }, [provider, ydoc]);

  return (
    <div>
      <ConnectionStatus status={status} />
      <CollaboratorAvatars provider={provider} />
      <EditorContent editor={editor} />
    </div>
  );
}
```

## O — Optimizations

### 1. Offline Support with IndexedDB

```typescript
import { IndexeddbPersistence } from 'y-indexeddb';

// Persist Yjs doc to IndexedDB for offline editing
const indexeddbProvider = new IndexeddbPersistence(docId, ydoc);

indexeddbProvider.on('synced', () => {
  console.log('Local data loaded from IndexedDB');
  // Now connect to server — Yjs will automatically merge
  // local offline changes with server state
});

// The magic: Yjs merge is commutative
// Offline edits + server edits → deterministic merge
// No manual conflict resolution needed!
```

### 2. Cursor Decoration Rendering

```typescript
// Remote cursors rendered as ProseMirror decorations
function renderCursor(user: { name: string; color: string }, position: number) {
  return Decoration.widget(position, () => {
    const cursor = document.createElement('span');
    cursor.className = 'collaboration-cursor';
    cursor.style.borderLeft = `2px solid ${user.color}`;

    const label = document.createElement('div');
    label.className = 'collaboration-cursor-label';
    label.style.backgroundColor = user.color;
    label.textContent = user.name;

    // Auto-hide label after 2 seconds of inactivity
    label.style.opacity = '1';
    setTimeout(() => { label.style.opacity = '0'; }, 2000);

    cursor.appendChild(label);
    return cursor;
  });
}

// CSS for smooth cursor animation
// .collaboration-cursor {
//   position: relative;
//   margin-left: -1px;
//   border-left: 2px solid;
//   pointer-events: none;
//   transition: top 0.1s ease-out; /* Smooth cursor movement */
// }
```

### 3. Version History with Snapshots

```typescript
import * as Y from 'yjs';

// Create a version snapshot
function createVersion(ydoc: Y.Doc, title: string): Uint8Array {
  return Y.encodeStateAsUpdate(ydoc);
  // Store this Uint8Array in the database
}

// Restore a version (for preview)
function previewVersion(snapshot: Uint8Array): Y.Doc {
  const previewDoc = new Y.Doc();
  Y.applyUpdate(previewDoc, snapshot);
  return previewDoc;
  // Render in a read-only TipTap instance
}

// Diff two versions
function diffVersions(older: Uint8Array, newer: Uint8Array): string {
  // Apply both to separate docs, extract text, run diff algorithm
  const oldDoc = new Y.Doc();
  const newDoc = new Y.Doc();
  Y.applyUpdate(oldDoc, older);
  Y.applyUpdate(newDoc, newer);
  // ... text diff logic
}
```

### 4. Large Document Performance

```typescript
// For documents with 100K+ characters:

// 1. Lazy loading: don't render the entire doc
// Split into "pages" or "blocks" (Notion-style)
interface Block {
  id: string;
  type: 'paragraph' | 'heading' | 'image' | 'table';
  ydoc: Y.Doc; // Each block has its own CRDT doc
}

// 2. Throttle remote updates to reduce re-renders
const throttledApply = throttle((update: Uint8Array) => {
  Y.applyUpdate(ydoc, update);
}, 50); // Max 20 updates/second from remote

// 3. Debounce save to server
const debouncedSave = debounce(() => {
  const update = Y.encodeStateAsUpdate(ydoc);
  api.saveDocument(docId, update);
}, 2000); // Save every 2 seconds of inactivity

ydoc.on('update', debouncedSave);
```

### 5. Permission-Aware Editing

```typescript
function useEditorPermissions(docId: string, userId: string) {
  const permission = useDocPermission(docId, userId);

  return {
    editable: permission === 'edit',
    canComment: permission === 'edit' || permission === 'comment',
    canView: true,

    // Dynamically toggle editor editability
    editorOptions: {
      editable: permission === 'edit',
      // View-only users still see cursors and real-time changes
      // but their input is ignored
    }
  };
}
```

**Staff-Level Challenges: Beyond Data Convergence**

### **. Solving Vector Clock Bloat & Metadata Growth**

**The Problem:** Every unique `clientId` in Yjs or Automerge creates an entry in the state vector. If a document is public or long-lived, the overhead of identifying "who owns what" can eventually exceed the size of the actual text.

- **The Interview Answer:**
  > "To handle identifier bloat, we implement **Client ID Recycling** and **Document Snapshotting (Compaction)**. We don't need the full causal history for all time. Once we reach a 'Global Stable State' (where we know all active clients have seen an update), we can 'compact' the history.
  >
  > Technically, this involves the server periodically generating a **'Canonical Snapshot'**. New clients download this compressed snapshot instead of the full log of every keystroke since 2022. We also map long `UUID` client IDs to small `integer` offsets (1, 2, 3...) in a local lookup table to save bytes in the binary wire format."

---

### **2. Solving Semantic Merging (The "Intent" Problem)**

**The Problem:** CRDTs guarantee _convergence_ (everyone sees the same thing) but not _intention_ (the result makes sense). If I move a block to Section A and you edit it while it was in Section B, the result might be a duplicated block or a broken reference.

- **The Interview Answer:**
  > "CRDTs solve for data integrity, but **Semantic Integrity** requires a **Block-Based Schema**. Instead of treating the whole doc as one long string, we treat it as an ordered tree of unique 'Block IDs' (like Notion).
  >
  > If a user moves a block, we move the _Reference ID_, not the text itself. For rich text conflicts (like overlapping bold/italic), we use the **'LWW' (Last Write Wins) register** or **'Add-Wins' set** logic for formatting attributes. If two users move the same block to different locations, we use a deterministic tie-breaker (e.g., highest Client ID) to ensure they land in the same spot, then provide a 'User B moved this' toast to the losing user for UX clarity."

---

### **3. Solving the "Split-Brain" (Long-term Offline) Problem**

**The Problem:** A user edits on a plane for 10 hours. In the meantime, the document has changed 80%. A blind merge might result in "Interleaving" (sentences mixed together like a salad).

- **The Interview Answer:**
  > "For high-latency or long-term offline merges, we move from **Automatic Merging** to **Proposed Merging**. When the client reconnects, we calculate a 'Diff' between the local state and the server's head.
  >
  > If the diff exceeds a **'Conflict Threshold'** (e.g., >20% of the document or overlapping lines), we fork the offline changes into a **'Side-Branch' or 'Draft Mode'**. We then present a 'Merge Review' UI—similar to a Git PR—where the user can 'Accept All' or 'Keep My Version'. This prevents the 'Shredded Text' effect where two people editing the same paragraph results in characters alternating from each user."

## Production Gotchas Rahul Has Debugged 🔥

1. **History vs. Collaboration**: TipTap's built-in `History` extension (undo/redo) conflicts with Yjs — it tracks local operations, not CRDT states. Always disable it and use Yjs's built-in undo manager: `new Y.UndoManager(yXmlFragment)`.
2. **Cursor Flickering**: Awareness updates fire on every keystroke, causing remote cursors to "flicker." Throttle awareness broadcasts to every 100ms and use CSS transitions for smooth cursor movement.
3. **Memory Leaks**: Yjs documents accumulate tombstones (deleted characters are marked, not removed). For long-lived documents, periodically "garbage collect" by creating a fresh snapshot.
4. **Tab Duplication**: If a user opens the same doc in two tabs, they'll see themselves as two collaborators with conflicting cursors. Detect this with `BroadcastChannel` and designate one tab as primary.
5. **Initial Load Blank Flash**: The Yjs doc is empty until the WebSocket syncs. Show a skeleton loader, and pre-populate from IndexedDB cache while waiting for the authoritative server state.

Next up: **#10: Design a Notification System** — push vs pull, real-time badges, notification grouping, and the read/unread state machine. 🔔

---

Public pages are mirrored here in full. Premium pages are listed with their summary only — the gated body stays behind server-side entitlement checks.
