PatternsReal-Time Architecture
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
| Aspect | OT | CRDT |
|---|---|---|
| Complexity | High | Medium |
| Conflict Resolution | Transform operations | Merge states |
| Undo/Redo | Complex | Simple |
| Offline Support | Harder | Easier |
| Performance | Fast | Slower (more data) |
| Use Case | Real-time collab | Offline-first |
🏢 Real-World Examples
Google Docs
// Uses custom OT implementation
// Server is source of truth
// Client transforms against server opsFigma
// CRDT-based
// Optimized for design tools
// Vector graphics syncNotion
// Block-based CRDT
// Each block is independent
// Easy undo/redo📚 Key Takeaways
- OT for real-time - Google Docs style
- CRDT for offline-first - Better for mobile
- Show remote cursors - Essential UX
- Handle conflicts - Don't lose data
- Test with latency - Simulate slow networks
- Undo/Redo - Harder than it looks
- Consider libraries - Yjs, Automerge, ShareDB
Collaborative editing is complex. Use a library (Yjs recommended) unless you have specific needs.