DevPrep
  • Interview Prep
  • Projects
  • Resources
  • Pricing
  • About Us
Submit Question
DevPrep
  • Pricing
  • About Us
Submit Question
  1. Home
  2. Articles
  3. Frontend Engineering
  4. System Design #9: Design a Collaborative Text Editor
XLinkedInReddit
MediumFrontend Engineering

System Design #9: Design a Collaborative Text Editor

D
DevPrep Team
9 min readΒ·13Β·1
Table of Contents
  • Hey folks, Rahul here πŸ‘‹
  • R β€” Requirements
  • Functional Requirements
  • Non-Functional Requirements
  • A β€” Architecture
  • OT vs CRDT: The Core Decision
  • Architecture with Yjs
  • D β€” Data Model
  • Document State
  • Awareness Protocol (Cursor Presence)
  • I β€” Interface Definition
  • WebSocket Protocol
  • TipTap + Yjs Integration
  • O β€” Optimizations
  • 1. Offline Support with IndexedDB
  • 2. Cursor Decoration Rendering
  • 3. Version History with Snapshots
  • 4. Large Document Performance
  • 5. Permission-Aware Editing
  • . Solving Vector Clock Bloat & Metadata Growth
  • 2. Solving Semantic Merging (The "Intent" Problem)
  • 3. Solving the "Split-Brain" (Long-term Offline) Problem
  • Production Gotchas Rahul Has Debugged πŸ”₯

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.

// 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:

// 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

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

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)

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

// 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

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

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

// 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

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

// 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

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. πŸ””

Related Articles

MediumFrontend Engineering

System Design #12: Design a Multi-Step Form Wizard

7 min read
MediumFrontend Engineering

Mastering Senior-Level JavaScript Interview Concepts

2 min read
MediumFrontend Engineering

System Design #14: Design an Image Carousel / Gallery

6 min read

Comments (0)

Sign in to leave a comment.

No comments yet. Be the first to comment.

Table of Contents

  • Hey folks, Rahul here πŸ‘‹
  • R β€” Requirements
  • Functional Requirements
  • Non-Functional Requirements
  • A β€” Architecture
  • OT vs CRDT: The Core Decision
  • Architecture with Yjs
  • D β€” Data Model
  • Document State
  • Awareness Protocol (Cursor Presence)
  • I β€” Interface Definition
  • WebSocket Protocol
  • TipTap + Yjs Integration
  • O β€” Optimizations
  • 1. Offline Support with IndexedDB
  • 2. Cursor Decoration Rendering
  • 3. Version History with Snapshots
  • 4. Large Document Performance
  • 5. Permission-Aware Editing
  • . Solving Vector Clock Bloat &amp; Metadata Growth
  • 2. Solving Semantic Merging (The "Intent" Problem)
  • 3. Solving the "Split-Brain" (Long-term Offline) Problem
  • Production Gotchas Rahul Has Debugged πŸ”₯

Series

View all Frontend Engineering articles β†’

Practice

  • JavaScript
  • DSA
  • Machine Coding
  • System Design

Resources

  • Learning Tracks
  • Articles
  • Roadmaps
  • Compare Concepts
  • Glossary
  • Developer Tools
  • All Questions

Company

  • About
  • Pricing

Legal

  • Privacy Policy
  • Terms of Service
DevPrep

Β© 2026 DevPrep. All rights reserved.