Back to Blog

Designing a Real-Time Collaborative Editing Feature Like Notion

A Deep Dive into CRDTs, Awareness Protocols, and Production Architecture


Introduction

Real-time collaborative editing has transformed from a niche feature into an expectation. Users want to see colleagues' cursors, watch text appear character-by-character, and never—ever—lose data to merge conflicts. Building this capability at scale requires understanding fundamental trade-offs between competing approaches, mastering distributed systems concepts, and carefully orchestrating multiple layers of your application stack.

This post provides a comprehensive architectural guide to building a collaborative editor like Notion. We'll explore the theoretical foundations, examine battle-tested libraries, design production-grade backend systems, and build a React state layer that handles conflicts gracefully.


Table of Contents

  1. The Fundamental Problem: Conflict Resolution
  2. CRDTs vs Operational Transformation: The Great Debate
  3. Yjs: The Gold Standard for CRDT Implementation
  4. Awareness Protocols: Cursor Presence and Selection Sharing
  5. Backend Architecture: WebSockets, Persistence, and Scaling
  6. React State Layer: Bridging CRDTs with UI
  7. Handling Edge Cases and Performance Optimization
  8. Putting It All Together

The Fundamental Problem: Conflict Resolution

When multiple users edit the same document simultaneously, conflicts are inevitable. The core challenge is ensuring all clients converge to the same state regardless of the order in which operations arrive—a property called eventual consistency.

Consider this scenario:

  1. User A types "Hello" at position 0
  2. User B types "World" at position 0 (before User A's changes arrive)
  3. Both operations reach the server

What should the document contain? The answer depends on your conflict resolution strategy:

  • Last-Write-Wins (LWW): Simple but destructive—"WorldHello" or "HelloWorld" depending on timestamps
  • Operational Transformation (OT): Transform operations so they apply in a canonical order
  • Conflict-free Replicated Data Types (CRDTs): Design data structures where operations are inherently commutative

CRDTs vs Operational Transformation: The Great Debate

Understanding Operational Transformation

OT was the dominant paradigm for decades, used by Google Docs, Etherpad, and early implementations of collaborative editing. The core idea is transformation functions that adjust concurrent operations relative to each other.

// Simplified OT concept
type Operation = {
  type: 'insert' | 'delete';
  position: number;
  content?: string;  // For inserts
  length?: number;  // For deletes
};

// Transform function: how do we adjust op2 relative to op1?
function transform(op1: Operation, op2: Operation): Operation {
  // If op2 targets a position after op1's insert, shift it
  if (op2.position > op1.position && op1.type === 'insert') {
    return { ...op2, position: op2.position + (op1.content?.length || 0) };
  }
  // Similar logic for deletes, overlapping ranges, etc.
  return op2;
}

Strengths of OT:

  • Mature, well-understood paradigm
  • Works well with centralized servers
  • Produces deterministic results

Weaknesses of OT:

  • Transformation functions become exponentially complex for rich text
  • Requires a central authority to order operations
  • Difficult to implement correctly (many subtle bugs in practice)
  • Doesn't handle offline scenarios well

Understanding CRDTs

CRDTs are data structures designed specifically for distributed systems where:

  1. Commutative: a ⊕ b = b ⊕ a (order doesn't matter)
  2. Associative: (a ⊕ b) ⊕ c = a ⊕ (b ⊕ c)
  3. Idempotent: a ⊕ a = a (applying twice has no additional effect)

For text editing, the most common CRDT is the RGA (Replicated Growable Array) or Yjs's Y.Text which uses a sequence CRDT.

// Simplified CRDT concept - using unique IDs instead of positions
interface CRDTChar {
  id: string;        // Unique identifier (Lamport timestamp + client ID)
  content: string;  // The actual character
  originLeft: string | null;  // ID of character to the left
  originRight: string | null; // ID of character to the right
}

// When inserting "H" at the beginning:
// - We don't use position 0
// - We reference the ID of the character that should be to the left
// - The CRDT math ensures eventual consistency

Strengths of CRDTs:

  • Fully decentralized—no server required for conflict resolution
  • Handles offline editing seamlessly
  • Mathematically proven convergence
  • Simpler implementation (no complex transform functions)

Weaknesses of CRDTs:

  • Higher memory overhead (unique IDs for every element)
  • Garbage collection is non-trivial
  • Learning curve for teams unfamiliar with distributed systems

Why Yjs (and CRDTs) Won

In 2025, CRDTs—particularly Yjs—have become the de facto standard for collaborative editing. The tipping point came from several factors:

  1. Simpler implementation: Yjs abstracts away the complexity
  2. Rich ecosystem: Bindings for Monaco, ProseMirror, Quill, TipTap
  3. Performance: Optimized memory usage and sync algorithms
  4. Offline-first: No server dependency for local editing

Key Insight: Unless you have a specific reason to use OT (legacy systems, existing infrastructure), choose CRDTs. The operational simplicity outweighs the memory overhead for most applications.


Yjs: The Gold Standard for CRDT Implementation

Yjs is a high-performance CRDT implementation that powers Notion, Figma, and countless other collaborative applications. Let's explore its architecture.

Core Concepts

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

// Create a Yjs document
const ydoc = new Y.Doc();

// Get a shared text type (the CRDT for text)
const ytext = ydoc.getText('content');

// Observe changes
ytext.observe((event) => {
  console.log('Changes:', event.delta);
  // delta contains the operational changes
});

// Make an edit
ytext.insert(0, 'Hello, ');  // Insert at position 0
ytext.delete(0, 1);         // Delete 1 character at position 0

The Delta Format

Yjs uses an operational format similar to Operational Transformation, but the operations are CRDT-aware:

// What you receive in the observer
const delta = [
  { retain: 5 },           // Skip 5 characters
  { insert: 'World' },     // Insert 'World'
  { delete: 3 },           // Delete 3 characters
];

// This is operationally equivalent to:
// "Hello Tom" -> insert "World" at position 5 -> delete 3 chars -> "Hello World"

Integrating with Text Editors

Yjs has first-class support for popular editor frameworks:

// TipTap integration (most common for Notion-like editors)
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';

const CollaborativeEditor = ({ documentId, user }) => {
  const [provider, setProvider] = useState(null);
  const [ydoc] = useState(() => new Y.Doc());

  useEffect(() => {
    const wsProvider = new WebsocketProvider(
      'wss://your-collaboration-server.com',
      documentId,
      ydoc
    );
    
    // Awareness protocol (covered next)
    wsProvider.awareness.setLocalStateField('user', {
      name: user.name,
      color: user.color,
    });
    
    setProvider(wsProvider);
    
    return () => wsProvider.destroy();
  }, [documentId, user]);

  const editor = useEditor({
    extensions: [
      StarterKit,
      Collaboration.configure({
        document: ydoc,
      }),
      CollaborationCursor.configure({
        provider: wsProvider,
        user: {
          name: user.name,
          color: user.color,
        },
      }),
    ],
  });

  return <EditorContent editor={editor} />;
};

ProseMirror Integration

For more control (like Notion's block-based approach):

import { yCollab } from 'y-prosemirror';
import { EditorState } from 'prosemirror-state';
import { EditorView } from 'prosemirror-view';
import { schema } from 'prosemirror-schema-basic';
import { exampleSetup } from 'prosemirror-example-setup';

const prosemirrorPlugin = yCollab(ytext, provider.awareness);

const state = EditorState.create({
  doc: schema.node('doc', null, [
    schema.node('paragraph', null, [schema.text('Start typing...')]),
  ]),
  plugins: exampleSetup().concat(prosemirrorPlugin),
});

const view = new EditorView(document.getElementById('editor'), {
  state,
});

Awareness Protocols: Cursor Presence and Selection Sharing

Beyond actual document content, collaborative editors need to show users who's online, where their cursor is, and what they're selecting. Yjs provides the Awareness protocol for this.

How Awareness Works

Awareness is a ephemeral, broadcast-based system that propagates state changes to all connected clients:

// Setting local awareness state
provider.awareness.setLocalStateField('user', {
  name: 'Alice',
  color: '#FF6B6B',
  avatar: 'https://...',
});

// What's being edited
provider.awareness.setLocalStateField('cursor', {
  anchor: 142,    // Cursor position
  head: 156,      // Selection end (if any)
  blockId: 'abc', // For block-based editors
});

// Observing awareness changes
provider.awareness.on('change', () => {
  const states = provider.awareness.getStates();
  
  states.forEach((state, clientId) => {
    if (clientId === provider.awareness.clientID) return;
    
    console.log(`${state.user.name} is at position ${state.cursor?.anchor}`);
  });
});

Rendering Cursors in TipTap

import CollaborationCursor from '@tiptap/extension-collaboration-cursor';

// The extension handles:
// - Rendering colored carets
// - Showing user names on hover
// - Selection highlights
// - Cleanup when users disconnect

const editor = useEditor({
  extensions: [
    CollaborationCursor.configure({
      provider: wsProvider,
      user: {
        name: user.name,
        color: user.color,
      },
    }),
  ],
});

// Custom styling for cursors
const cursorStyles = `
  .collaboration-cursor__caret {
    border-left: 1px solid #0d0d0d;
    border-right: 1px solid #0d0d0d;
    margin-left: -1px;
    margin-right: -1px;
    pointer-events: none;
    position: relative;
    word-break: normal;
  }
  
  .collaboration-cursor__label {
    border-radius: 3px 3px 3px 0;
    color: #0d0d0d;
    font-size: 12px;
    font-style: normal;
    font-weight: 600;
    left: -1px;
    line-height: normal;
    padding: 0.1rem 0.3rem;
    position: absolute;
    top: -1.4em;
    user-select: none;
    white-space: nowrap;
  }
`;

Advanced Awareness: Block-Level Presence

For Notion-style block editors, you need awareness at the block level:

// Custom awareness for block editing
const BlockAwareness = {
  onInit(extension) {
    const { provider, editor } = extension.options;
    
    provider.awareness.on('change', () => {
      const states = provider.awareness.getStates();
      const activeBlocks = new Map();
      
      states.forEach((state, clientId) => {
        if (clientId === provider.awareness.clientID) return;
        
        if (state.blockId) {
          const existing = activeBlocks.get(state.blockId) || [];
          existing.push({
            clientId,
            user: state.user,
            cursor: state.cursor,
          });
          activeBlocks.set(state.blockId, existing);
        }
      });
      
      // Update editor UI to show who's editing which block
      extension.editor.commands.updateAttributes('block', {
        'data-active-users': JSON.stringify([...activeBlocks.entries()]),
      });
    });
    
    // Broadcast block focus
    editor.on('selectionUpdate', ({ editor }) => {
      const { from } = editor.state.selection;
      const blockId = findBlockIdAtPosition(editor, from);
      
      provider.awareness.setLocalStateField('cursor', {
        anchor: from,
        blockId,
      });
    });
  },
};

Backend Architecture: WebSockets, Persistence, and Scaling

The Collaboration Server

Yjs works with various network providers. For production, you'll need:

// y-websocket server (Node.js)
const { WebSocketServer } = require('ws');
const { setupWSConnection } = require('y-websocket/bin/utils');

const wss = new WebSocketServer({ port: 1234 });

wss.on('connection', (conn, req) => {
  // Extract document ID from URL
  const docName = req.url.slice(1).split('?')[0];
  
  // Optional: authenticate here
  // const token = parseToken(req);
  // if (!token) { conn.close(); return; }
  
  setupWSConnection(conn, req, { docName });
});

Production Architecture

┌─────────────────────────────────────────────────────────────┐
│                        Load Balancer                         │
│                      (AWS ALB / CloudFlare)                  │
└─────────────────────────┬───────────────────────────────────┘
                          │
          ┌───────────────┼───────────────┐
          │               │               │
          ▼               ▼               ▼
    ┌──────────┐   ┌──────────┐   ┌──────────┐
    │ WebSocket│   │ WebSocket│   │ WebSocket│
    │ Server 1 │   │ Server 2 │   │ Server N │
    └────┬─────┘   └────┬─────┘   └────┬─────┘
         │              │              │
         └──────────────┼──────────────┘
                        │
                        ▼
            ┌───────────────────────┐
            │   Redis Pub/Sub      │
            │   (Message Bus)      │
            └───────────┬───────────┘
                        │
         ┌──────────────┼──────────────┐
         │              │              │
         ▼              ▼              ▼
   ┌──────────┐  ┌──────────┐  ┌──────────┐
   │ Document │  │ Document │  │ Document │
   │ Storage  │  │ Storage  │  │ Storage  │
   │ (LevelDB)│  │ (LevelDB)│  │ (LevelDB)│
   └──────────┘  └──────────┘  └──────────┘

Persistence Strategy

Yjs documents need to be persisted. Here's a production-grade approach:

// Custom persistence layer with LevelDB
const LeveldbPersistence = require('y-leveldb');
const Y = require('yjs');

class DocumentPersistence {
  constructor(dataDir) {
    this.persistence = new LeveldbPersistence(dataDir);
    this.cache = new Map();
  }
  
  async getYDoc(docName) {
    // Check cache first
    if (this.cache.has(docName)) {
      return this.cache.get(docName);
    }
    
    // Load from persistence
    const ydoc = await this.persistence.getYDoc(docName);
    
    // Add to cache
    this.cache.set(docName, ydoc);
    
    // Store updates to persistence
    ydoc.on('update', (update, origin) => {
      if (origin !== 'sync') {
        this.persistence.storeUpdate(docName, update);
      }
    });
    
    return ydoc;
  }
  
  // Cleanup when document not in use
  async flushDoc(docName) {
    const ydoc = this.cache.get(docName);
    if (ydoc) {
      ydoc.destroy();
      this.cache.delete(docName);
    }
  }
}

// Binary encoding for storage
const encodeStateAsUpdate = (ydoc) => {
  const state = Y.encodeStateAsUpdate(ydoc);
  return Buffer.from(state).toString('base64');
};

Scaling with Redis

For horizontal scaling, use Redis Pub/Sub to sync updates across servers:

const Redis = require('ioredis');
const Y = require('yjs');

class RedisPersistence {
  constructor() {
    this.redis = new Redis(process.env.REDIS_URL);
    this.pubsub = new Redis(process.env.REDIS_URL);
    this.subscriptions = new Map();
  }
  
  async subscribe(docName, callback) {
    const channel = `yjs:${docName}`;
    
    if (!this.subscriptions.has(docName)) {
      await this.pubsub.subscribe(channel);
    }
    
    this.pubsub.on('message', (ch, message) => {
      if (ch === channel) {
        const update = Buffer.from(message, 'base64');
        callback(update);
      }
    });
  }
  
  async publish(docName, update) {
    const channel = `yjs:${docName}`;
    await this.redis.publish(
      channel, 
      Buffer.from(update).toString('base64')
    );
  }
}

Handling Connection Drops and Reconnection

class RobustConnection {
  constructor(provider) {
    this.provider = provider;
    this.pendingUpdates = [];
    this.synced = false;
    
    this.setupEventHandlers();
  }
  
  setupEventHandlers() {
    // Track sync status
    this.provider.on('status', ({ status }) => {
      console.log('Connection status:', status);
      
      if (status === 'connected') {
        this.flushPendingUpdates();
      }
    });
    
    // Handle sync (when we receive full document state)
    this.provider.on('sync', (isSynced) => {
      this.synced = isSynced;
      if (isSynced) {
        this.flushPendingUpdates();
      }
    });
    
    // Queue updates when disconnected
    this.provider.doc.on('update', (update, origin) => {
      if (origin !== 'remote' && !this.synced) {
        this.pendingUpdates.push(update);
      }
    });
  }
  
  flushPendingUpdates() {
    while (this.pendingUpdates.length > 0) {
      const update = this.pendingUpdates.shift();
      Y.applyUpdate(this.provider.doc, update);
    }
  }
}

React State Layer: Bridging CRDTs with UI

The React-Yjs Integration

React and CRDTs have different mental models. React wants deterministic, render-based state updates. CRDTs are event-driven and async. Here's how to bridge them:

// hooks/useCollaborativeEditor.ts
import { useState, useEffect, useRef, useCallback } from 'react';
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';

interface UseCollaborativeEditorOptions {
  documentId: string;
  user: {
    id: string;
    name: string;
    color: string;
  };
  serverUrl: string;
}

export function useCollaborativeEditor({
  documentId,
  user,
  serverUrl,
}: UseCollaborativeEditorOptions) {
  const [isConnected, setIsConnected] = useState(false);
  const [activeUsers, setActiveUsers] = useState([]);
  const [syncStatus, setSyncStatus] = useState<'synced' | 'syncing' | 'disconnected'>('disconnected');
  
  // Use refs to avoid re-renders causing provider recreation
  const ydocRef = useRef<Y.Doc>(new Y.Doc());
  const providerRef = useRef<WebsocketProvider | null>(null);
  
  useEffect(() => {
    // Create provider
    const provider = new WebsocketProvider(
      serverUrl,
      documentId,
      ydocRef.current
    );
    
    // Set user info for awareness
    provider.awareness.setLocalStateField('user', {
      id: user.id,
      name: user.name,
      color: user.color,
    });
    
    // Track connection status
    provider.on('status', ({ status }) => {
      setIsConnected(status === 'connected');
      setSyncStatus(status === 'connected' ? 'synced' : 'disconnected');
    });
    
    // Track sync progress
    provider.on('sync', (isSynced) => {
      setSyncStatus(isSynced ? 'synced' : 'syncing');
    });
    
    // Track active users
    const updateActiveUsers = () => {
      const states = provider.awareness.getStates();
      const users = Array.from(states.entries())
        .filter(([clientId]) => clientId !== provider.awareness.clientID)
        .map(([, state]) => ({
          clientId: state.clientId,
          user: state.user,
          cursor: state.cursor,
        }));
      setActiveUsers(users);
    };
    
    provider.awareness.on('change', updateActiveUsers);
    
    providerRef.current = provider;
    
    return () => {
      provider.destroy();
    };
  }, [documentId, serverUrl, user.id, user.name, user.color]);
  
  return {
    ydoc: ydocRef.current,
    provider: providerRef.current,
    isConnected,
    activeUsers,
    syncStatus,
  };
}

Integration with TipTap

// components/CollaborativeEditor.tsx
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Collaboration from '@tiptap/extension-collaboration';
import CollaborationCursor from '@tiptap/extension-collaboration-cursor';
import { useCollaborativeEditor } from '../hooks/useCollaborativeEditor';

export function CollaborativeEditor({ 
  documentId, 
  user, 
  onLoad 
}: {
  documentId: string;
  user: { id: string; name: string; color: string };
  onLoad?: (editor: any) => void;
}) {
  const { ydoc, provider, isConnected, activeUsers, syncStatus } = 
    useCollaborativeEditor({
      documentId,
      user,
      serverUrl: process.env.NEXT_PUBLIC_COLLAB_URL!,
    });
  
  const editor = useEditor({
    extensions: [
      StarterKit.configure({
        history: false, // Disable - Yjs handles history
      }),
      Collaboration.configure({
        document: ydoc,
      }),
      CollaborationCursor.configure({
        provider: provider!,
        user: {
          name: user.name,
          color: user.color,
        },
      }),
    ],
    onCreate: ({ editor }) => {
      onLoad?.(editor);
    },
  });
  
  return (
    <div className="collaborative-editor">
      <EditorStatus 
        isConnected={isConnected} 
        syncStatus={syncStatus}
        activeUsers={activeUsers}
      />
      <EditorContent editor={editor} />
    </div>
  );
}

Undo/Redo with Yjs

Yjs has built-in undo manager:

import { UndoManager } from 'yjs';

// Create undo manager for specific types
const undoManager = new UndoManager(ytext, {
  trackedOrigins: new Set([null]), // Track all changes
  captureTimeout: 500, // Group changes within 500ms
});

// Undo/redo
const undo = () => undoManager.undo();
const redo = () => undoManager.redo();

// Check availability
const canUndo = undoManager.undoStack.length > 0;
const canRedo = undoManager.redoStack.length > 0;

// In React:
<button onClick={undo} disabled={!canUndo}>
  Undo
</button>
<button onClick={redo} disabled={!canRedo}>
  Redo
</button>

Handling Edge Cases and Performance Optimization

Conflict Resolution Edge Cases

Concurrent Block Operations:

// Block-based editing requires careful handling
interface BlockOperation {
  type: 'insert' | 'delete' | 'move' | 'update';
  blockId: string;
  position?: number;
  content?: any;
  parentId?: string;
}

// Use Y.Map for block metadata
const yblocks = ydoc.getMap('blocks');

// Handle concurrent block operations
yblocks.observe((event) => {
  event.changes.keys.forEach((change, key) => {
    switch (change.action) {
      case 'add':
        // New block added
        break;
      case 'update':
        // Block content updated
        break;
      case 'delete':
        // Block deleted - check for conflicts
        handleBlockDeletion(key);
        break;
    }
  });
});

Garbage Collection:

CRDTs grow unbounded. Yjs provides two strategies:

// Strategy 1: Snapshots (periodically create a clean state)
const snapshot = Y.snapshot(ydoc);

// Strategy 2: Patched updates (remove deleted content)
const encoded = Y.encodeStateAsUpdate(ydoc);
Y.writeUpdate(ydoc, (update) => {
  // Remove tombstones for deleted items
  return removeTombstones(update);
});

// Strategy 3: Trim history
const origLength = ytext.toString().length;
// Only keep recent history based on document size
const maxHistory = Math.max(1000, origLength * 2);

Performance Optimization

Debouncing Updates:

// Don't sync every keystroke - batch updates
const debouncedProvider = new Proxy(provider, {
  get(target, prop) {
    if (prop === 'awareness') {
      return target.awareness;
    }
    
    const value = target[prop];
    if (typeof value === 'function' && prop === 'sync') {
      return debounce(value, 100);
    }
    return value;
  },
});

// Virtualization for large documents
const VirtualizedEditor = ({ ytext, visibleRange }) => {
  const [visibleContent, setVisibleContent] = useState('');
  
  useEffect(() => {
    // Only render visible portion
    const text = ytext.toString();
    const start = visibleRange.start;
    const end = visibleRange.end;
    setVisibleContent(text.slice(start, end));
  }, [ytext, visibleRange]);
  
  return <Editor content={visibleContent} offset={visibleRange.start} />;
};

Putting It All Together

Building a production-grade collaborative editor involves:

  1. Choose CRDTs over OT for simpler implementation and offline support
  2. Use Yjs as your CRDT implementation—it's battle-tested and has excellent bindings
  3. Implement awareness protocols for real-time cursor/selection sharing
  4. Design scalable backend with WebSocket servers, Redis Pub/Sub, and persistent storage
  5. Bridge CRDT events to React carefully to avoid performance issues
  6. Handle edge cases like concurrent block operations and garbage collection

The architectural pattern looks like this:

// Final architecture summary

// 1. Yjs Document (the source of truth)
const ydoc = new Y.Doc();

// 2. Shared data types
const ytext = ydoc.getText('content');
const ymap = ydoc.getMap('metadata');

// 3. Network provider
const provider = new WebsocketProvider(wsUrl, docId, ydoc);

// 4. Awareness
provider.awareness.setLocalStateField('user', { name, color });

// 5. Editor integration (TipTap/ProseMirror)
const editor = useEditor({
  extensions: [
    Collaboration.configure({ document: ydoc }),
    CollaborationCursor.configure({ provider }),
  ],
});

// 6. React UI
return (
  <>
    <ConnectionStatus status={syncStatus} />
    <ActiveUsers users={activeUsers} />
    <EditorContent editor={editor} />
  </>
);

This architecture powers some of the most sophisticated collaborative applications today. While the complexity is non-trivial, the Yjs ecosystem has matured enough that you don't need to be a distributed systems PhD to implement it successfully.

The key insight is that you don't need to understand all the CRDT mathematics—you need to understand how to integrate Yjs correctly, handle the edge cases, and build a robust backend. The math handles itself.


Further Reading


This post is part of an advanced software architecture series. Next in the series: "Module Federation in 2025: Micro-Frontends That Actually Work"

What did you think?

© 2026 Vidhya Sagar Thakur. All rights reserved.