WebSockets & Real-Time Apps
Real-Time Collaborative Editing
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:
- Implement a collaborative rich-text editor using ContentEditable
- Show colored cursors for each user with their names
- Display online users list with presence indicators
- Implement basic OT or CRDT for conflict resolution
- Add selection highlighting (show what other users have selected)
- Persist document history to database
- 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