Designing a Real-Time Collaborative Editing Feature Like Notion
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
- The Fundamental Problem: Conflict Resolution
- CRDTs vs Operational Transformation: The Great Debate
- Yjs: The Gold Standard for CRDT Implementation
- Awareness Protocols: Cursor Presence and Selection Sharing
- Backend Architecture: WebSockets, Persistence, and Scaling
- React State Layer: Bridging CRDTs with UI
- Handling Edge Cases and Performance Optimization
- 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:
- User A types "Hello" at position 0
- User B types "World" at position 0 (before User A's changes arrive)
- 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:
- Commutative:
a ⊕ b = b ⊕ a(order doesn't matter) - Associative:
(a ⊕ b) ⊕ c = a ⊕ (b ⊕ c) - 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:
- Simpler implementation: Yjs abstracts away the complexity
- Rich ecosystem: Bindings for Monaco, ProseMirror, Quill, TipTap
- Performance: Optimized memory usage and sync algorithms
- 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:
- Choose CRDTs over OT for simpler implementation and offline support
- Use Yjs as your CRDT implementation—it's battle-tested and has excellent bindings
- Implement awareness protocols for real-time cursor/selection sharing
- Design scalable backend with WebSocket servers, Redis Pub/Sub, and persistent storage
- Bridge CRDT events to React carefully to avoid performance issues
- 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
- Yjs Documentation
- CRDTs and the Quest for Distributed Eventual Consistency
- ProseMirror Collaboration Guide
- TipTap Collaboration Extension
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?