WebSockets & Real-Time Apps

Real-Time Collaborative Editing

20 min Lesson 17 of 35

Real-Time Collaborative Editing

Collaborative editing allows multiple users to simultaneously edit the same document, seeing each other's changes in real-time. This technology powers applications like Google Docs, Figma, and Notion.

Collaborative Editing Concepts

Key challenges in real-time collaboration:

  • Conflict Resolution: What happens when two users edit the same text simultaneously?
  • Consistency: Ensuring all users see the same final state
  • Latency: Handling network delays gracefully
  • Offline Support: Allowing edits when disconnected
  • Presence: Showing who else is editing

Basic Collaborative Text Editor

Let's build a simple collaborative editor:

<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Collaborative Editor</title> <style> .editor-container { max-width: 800px; margin: 20px auto; } #editor { width: 100%; min-height: 400px; padding: 15px; border: 1px solid #ddd; border-radius: 4px; font-family: monospace; font-size: 14px; } .user-cursors { position: relative; } .cursor { position: absolute; width: 2px; height: 20px; background: #2563eb; pointer-events: none; } .cursor-label { position: absolute; top: -20px; left: 0; background: #2563eb; color: white; padding: 2px 6px; border-radius: 3px; font-size: 11px; white-space: nowrap; } .online-users { display: flex; gap: 10px; margin-bottom: 10px; } .user-badge { padding: 5px 12px; background: #f3f4f6; border-radius: 20px; font-size: 13px; } </style> </head> <body> <div class="editor-container"> <div class="online-users" id="onlineUsers"></div> <div class="user-cursors"> <textarea id="editor" placeholder="Start typing..."></textarea> <div id="cursors"></div> </div> </div> <script> const ws = new WebSocket('ws://localhost:8080'); const editor = document.getElementById('editor'); const userId = generateUserId(); const userName = prompt('Enter your name:') || 'Anonymous'; let isRemoteChange = false; ws.onopen = () => { ws.send(JSON.stringify({ type: 'join', userId, userName })); }; editor.addEventListener('input', (e) => { if (isRemoteChange) return; const content = editor.value; const cursorPosition = editor.selectionStart; ws.send(JSON.stringify({ type: 'edit', userId, content, cursorPosition })); }); editor.addEventListener('selectionchange', () => { ws.send(JSON.stringify({ type: 'cursor', userId, position: editor.selectionStart })); }); ws.onmessage = (event) => { const data = JSON.parse(event.data); switch (data.type) { case 'edit': if (data.userId !== userId) { isRemoteChange = true; const cursorPos = editor.selectionStart; editor.value = data.content; editor.setSelectionRange(cursorPos, cursorPos); isRemoteChange = false; } break; case 'cursor': if (data.userId !== userId) { updateRemoteCursor(data.userId, data.userName, data.position); } break; case 'users': updateOnlineUsers(data.users); break; } }; function generateUserId() { return 'user_' + Math.random().toString(36).substr(2, 9); } function updateRemoteCursor(userId, userName, position) { // Implementation for showing remote cursors } function updateOnlineUsers(users) { const container = document.getElementById('onlineUsers'); container.innerHTML = users.map(user => `<div class="user-badge">${user.name}</div>` ).join(''); } </script> </body> </html>
Note: This basic implementation doesn't handle concurrent edits properly. For production use, you need Operational Transformation or CRDTs.

Operational Transformation (OT) Basics

OT is an algorithm that maintains consistency across concurrent edits:

class OperationalTransform { // Operation types static INSERT = 'insert'; static DELETE = 'delete'; static RETAIN = 'retain'; // Transform two operations against each other static transform(op1, op2) { if (op1.type === this.INSERT && op2.type === this.INSERT) { if (op1.position < op2.position) { return [op1, { ...op2, position: op2.position + op1.text.length }]; } else { return [{ ...op1, position: op1.position + op2.text.length }, op2]; } } if (op1.type === this.INSERT && op2.type === this.DELETE) { if (op1.position <= op2.position) { return [op1, { ...op2, position: op2.position + op1.text.length }]; } else if (op1.position > op2.position + op2.length) { return [{ ...op1, position: op1.position - op2.length }, op2]; } else { return [{ ...op1, position: op2.position }, op2]; } } if (op1.type === this.DELETE && op2.type === this.INSERT) { if (op2.position <= op1.position) { return [{ ...op1, position: op1.position + op2.text.length }, op2]; } else if (op2.position > op1.position + op1.length) { return [op1, { ...op2, position: op2.position - op1.length }]; } else { return [op1, { ...op2, position: op1.position }]; } } if (op1.type === this.DELETE && op2.type === this.DELETE) { // Handle overlapping deletes return this.transformDeletes(op1, op2); } return [op1, op2]; } static transformDeletes(op1, op2) { const op1End = op1.position + op1.length; const op2End = op2.position + op2.length; if (op1End <= op2.position) { return [op1, { ...op2, position: op2.position - op1.length }]; } else if (op2End <= op1.position) { return [{ ...op1, position: op1.position - op2.length }, op2]; } else { // Overlapping deletes - need complex resolution const overlapStart = Math.max(op1.position, op2.position); const overlapEnd = Math.min(op1End, op2End); const overlapLength = overlapEnd - overlapStart; return [ { ...op1, length: op1.length - overlapLength }, { ...op2, length: op2.length - overlapLength } ]; } } // Apply operation to text static apply(text, operation) { switch (operation.type) { case this.INSERT: return text.slice(0, operation.position) + operation.text + text.slice(operation.position); case this.DELETE: return text.slice(0, operation.position) + text.slice(operation.position + operation.length); default: return text; } } } // Usage example const text = 'Hello World'; const op1 = { type: 'insert', position: 5, text: ' Beautiful' }; const op2 = { type: 'delete', position: 6, length: 5 }; const [transformedOp1, transformedOp2] = OperationalTransform.transform(op1, op2); console.log(transformedOp1, transformedOp2);

Conflict-free Replicated Data Types (CRDTs)

CRDTs are data structures that automatically resolve conflicts:

class CRDT_Text { constructor(siteId) { this.siteId = siteId; this.characters = []; this.clock = 0; } // Insert character at position insert(position, char) { this.clock++; const id = { position: this.generatePositionBetween( this.characters[position - 1], this.characters[position] ), siteId: this.siteId, clock: this.clock }; const character = { id, value: char }; this.characters.splice(position, 0, character); return { type: 'insert', character }; } // Delete character at position delete(position) { const character = this.characters[position]; this.characters.splice(position, 1); return { type: 'delete', id: character.id }; } // Apply remote insert remoteInsert(character) { const index = this.findInsertIndex(character.id); this.characters.splice(index, 0, character); } // Apply remote delete remoteDelete(id) { const index = this.characters.findIndex(c => this.compareIds(c.id, id) === 0); if (index !== -1) { this.characters.splice(index, 1); } } // Generate position between two characters generatePositionBetween(before, after) { const beforePos = before ? before.id.position : [0]; const afterPos = after ? after.id.position : [this.clock + 1]; // Simple implementation - production would be more sophisticated return [(beforePos[0] + afterPos[0]) / 2]; } // Find where to insert based on position findInsertIndex(id) { let left = 0; let right = this.characters.length; while (left < right) { const mid = Math.floor((left + right) / 2); const comparison = this.compareIds(this.characters[mid].id, id); if (comparison < 0) { left = mid + 1; } else { right = mid; } } return left; } // Compare two IDs for ordering compareIds(id1, id2) { for (let i = 0; i < Math.min(id1.position.length, id2.position.length); i++) { if (id1.position[i] !== id2.position[i]) { return id1.position[i] - id2.position[i]; } } if (id1.position.length !== id2.position.length) { return id1.position.length - id2.position.length; } return id1.siteId.localeCompare(id2.siteId); } // Get text content toString() { return this.characters.map(c => c.value).join(''); } } // Usage const doc1 = new CRDT_Text('site1'); const doc2 = new CRDT_Text('site2'); const op1 = doc1.insert(0, 'H'); const op2 = doc1.insert(1, 'i'); // Apply operations to replica doc2.remoteInsert(op1.character); doc2.remoteInsert(op2.character); console.log(doc1.toString()); // "Hi" console.log(doc2.toString()); // "Hi"
Best Practice: CRDTs are simpler to implement than OT and automatically resolve conflicts. Use CRDTs for new projects unless you have specific OT requirements.

Cursor Positions and Selection

Show where other users are editing:

class CursorManager { constructor(editorElement) { this.editor = editorElement; this.cursors = new Map(); this.container = document.getElementById('cursors'); } updateCursor(userId, userName, position, color = '#2563eb') { let cursor = this.cursors.get(userId); if (!cursor) { cursor = this.createCursor(userId, userName, color); this.cursors.set(userId, cursor); } this.positionCursor(cursor, position); } createCursor(userId, userName, color) { const cursor = document.createElement('div'); cursor.className = 'cursor'; cursor.style.background = color; cursor.dataset.userId = userId; const label = document.createElement('div'); label.className = 'cursor-label'; label.style.background = color; label.textContent = userName; cursor.appendChild(label); this.container.appendChild(cursor); return cursor; } positionCursor(cursor, position) { // Calculate pixel position from character position const coords = this.getCoordinatesForPosition(position); cursor.style.left = coords.x + 'px'; cursor.style.top = coords.y + 'px'; } getCoordinatesForPosition(position) { // Create temporary span at position to measure const text = this.editor.value; const before = text.substring(0, position); // Count lines and columns const lines = before.split('\n'); const line = lines.length - 1; const column = lines[lines.length - 1].length; // Calculate pixel coordinates const lineHeight = 20; // Approximate line height const charWidth = 8.5; // Approximate character width (monospace) return { x: column * charWidth + 15, // 15px padding y: line * lineHeight + 15 }; } removeCursor(userId) { const cursor = this.cursors.get(userId); if (cursor) { cursor.remove(); this.cursors.delete(userId); } } hideCursor(userId) { const cursor = this.cursors.get(userId); if (cursor) { cursor.style.display = 'none'; } } showCursor(userId) { const cursor = this.cursors.get(userId); if (cursor) { cursor.style.display = 'block'; } } } // Usage const cursorManager = new CursorManager(document.getElementById('editor')); ws.onmessage = (event) => { const data = JSON.parse(event.data); if (data.type === 'cursor') { cursorManager.updateCursor( data.userId, data.userName, data.position, data.color ); } };

Shared Document State

Server-side document state management:

class CollaborativeDocument { constructor(docId) { this.docId = docId; this.content = ''; this.version = 0; this.operations = []; this.users = new Map(); } addUser(userId, userName, ws) { this.users.set(userId, { id: userId, name: userName, ws: ws, cursor: 0, color: this.generateColor() }); // Send current document state to new user ws.send(JSON.stringify({ type: 'init', content: this.content, version: this.version, users: Array.from(this.users.values()).map(u => ({ id: u.id, name: u.name, color: u.color })) })); // Notify others of new user this.broadcast({ type: 'user-join', userId, userName, color: this.users.get(userId).color }, userId); } removeUser(userId) { this.users.delete(userId); this.broadcast({ type: 'user-leave', userId }); } applyOperation(userId, operation) { // Apply operation to document this.content = this.executeOperation(this.content, operation); this.version++; this.operations.push({ userId, operation, version: this.version, timestamp: Date.now() }); // Broadcast to all users except sender this.broadcast({ type: 'operation', userId, operation, version: this.version }, userId); } executeOperation(content, operation) { switch (operation.type) { case 'insert': return content.slice(0, operation.position) + operation.text + content.slice(operation.position); case 'delete': return content.slice(0, operation.position) + content.slice(operation.position + operation.length); default: return content; } } updateCursor(userId, position) { const user = this.users.get(userId); if (user) { user.cursor = position; this.broadcast({ type: 'cursor', userId, userName: user.name, position, color: user.color }, userId); } } broadcast(message, excludeUserId = null) { this.users.forEach((user, userId) => { if (userId !== excludeUserId && user.ws.readyState === WebSocket.OPEN) { user.ws.send(JSON.stringify(message)); } }); } generateColor() { const colors = [ '#2563eb', '#dc2626', '#16a34a', '#ca8a04', '#9333ea', '#ea580c', '#0891b2', '#be123c' ]; return colors[this.users.size % colors.length]; } } // Server implementation const documents = new Map(); wss.on('connection', (ws) => { let currentDoc = null; let userId = null; ws.on('message', (message) => { const data = JSON.parse(message); switch (data.type) { case 'join': userId = data.userId; const docId = data.docId || 'default'; if (!documents.has(docId)) { documents.set(docId, new CollaborativeDocument(docId)); } currentDoc = documents.get(docId); currentDoc.addUser(userId, data.userName, ws); break; case 'operation': if (currentDoc) { currentDoc.applyOperation(userId, data.operation); } break; case 'cursor': if (currentDoc) { currentDoc.updateCursor(userId, data.position); } break; } }); ws.on('close', () => { if (currentDoc && userId) { currentDoc.removeUser(userId); } }); });
Warning: This implementation doesn't persist documents to disk or database. Add persistence for production use.

Exercise: Build a Collaborative Notes App

Create a Multi-User Notes Application:
  1. Implement a collaborative rich-text editor using ContentEditable
  2. Show colored cursors for each user with their names
  3. Display online users list with presence indicators
  4. Implement basic OT or CRDT for conflict resolution
  5. Add selection highlighting (show what other users have selected)
  6. Persist document history to database
  7. Bonus: Add commenting functionality on selected text

Summary

  • Collaborative editing requires conflict resolution algorithms (OT or CRDTs)
  • Operational Transformation transforms concurrent operations to maintain consistency
  • CRDTs automatically resolve conflicts and are easier to implement
  • Show cursor positions and selections to improve collaboration awareness
  • Maintain shared document state on the server
  • Handle user presence (join, leave, online status)
  • Consider offline support and operation queuing