Building a Real-Time Collaborative Code Editor with WebSockets and Operational Transformation
Building a Real-Time Collaborative Code Editor with WebSockets and Operational Transformation
Why Build a Collaborative Editor?
Real-time collaborative editors like Google Docs or VS Code Live Share have become essential tools for modern software engineering. Building one teaches you critical concepts: WebSocket communication, conflict resolution, operational transformation (OT), and distributed state management. This tutorial walks you through creating a functioning collaborative code editor from scratch.
What You'll Build
A web-based code editor where multiple users can edit the same document simultaneously, with changes syncing in real-time and conflicts resolved automatically using operational transformation.
Tech Stack:
- Backend: Node.js with Express and WS (WebSocket library)
- Frontend: Vanilla JavaScript with CodeMirror editor
- Algorithm: Operational Transformation for conflict resolution
Prerequisites
- Node.js 18+ installed
- Basic understanding of JavaScript promises and async/await
- Familiarity with HTTP and WebSockets
Step 1: Set Up the Project Structure
Create a new directory and initialize your project:
mkdir collaborative-editor
cd collaborative-editor
npm init -y
npm install express ws uuid
npm install --save-dev nodemon
Create this directory structure:
collaborative-editor/
├── server.js
├── package.json
├── public/
│ ├── index.html
│ ├── style.css
│ └── client.js
Step 2: Build the WebSocket Server
Create server.js with WebSocket functionality and operational transformation logic:
const express = require('express');
const http = require('http');
const WebSocket = require('ws');
const { v4: uuidv4 } = require('uuid');
const path = require('path');
const app = express();
const server = http.createServer(app);
const wss = new WebSocket.Server({ server });
app.use(express.static('public'));
app.use(express.json());
// Store documents and their operations
const documents = new Map();
const clients = new Map();
class Document {
constructor(id) {
this.id = id;
this.content = '';
this.operations = [];
this.version = 0;
}
}
// Operational Transformation functions
function transform(op1, op2) {
// Transform op1 against op2 (op2 was applied before op1)
if (op1.type === 'insert' && op2.type === 'insert') {
if (op1.position <= op2.position) {
return op1;
} else {
return {
...op1,
position: op1.position + op2.text.length
};
}
}
if (op1.type === 'delete' && op2.type === 'insert') {
if (op1.position >= op2.position + op2.text.length) {
return {
...op1,
position: op1.position - op2.text.length
};
} else if (op1.position + op1.length <= op2.position) {
return op1;
} else {
// Overlapping - adjust delete range
const overlap = Math.min(op1.position + op1.length, op2.position + op2.text.length) -
Math.max(op1.position, op2.position);
return {
...op1,
length: op1.length - overlap
};
}
}
if (op1.type === 'insert' && op2.type === 'delete') {
if (op1.position >= op2.position + op2.length) {
return {
...op1,
position: op1.position - op2.length
};
} else if (op1.position <= op2.position) {
return op1;
} else {
return {
...op1,
position: op2.position
};
}
}
if (op1.type === 'delete' && op2.type === 'delete') {
if (op1.position >= op2.position + op2.length) {
return {
...op1,
position: op1.position - op2.length
};
} else if (op1.position + op1.length <= op2.position) {
return op1;
} else {
const start = Math.max(op1.position, op2.position);
const end = Math.min(op1.position + op1.length, op2.position + op2.length);
const overlap = end - start;
if (op1.position >= op2.position) {
return {
...op1,
position: op2.position,
length: op1.length - overlap
};
} else {
return {
...op1,
length: op1.length - overlap
};
}
}
}
return op1;
}
wss.on('connection', (ws) => {
const clientId = uuidv4();
clients.set(clientId, ws);
ws.on('message', (message) => {
const data = JSON.parse(message);
switch (data.type) {
case 'join': {
const { documentId } = data;
if (!documents.has(documentId)) {
documents.set(documentId, new Document(documentId));
}
const doc = documents.get(documentId);
ws.send(JSON.stringify({
type: 'joined',
documentId,
content: doc.content,
version: doc.version,
clientId
}));
// Notify other clients
clients.forEach((client, id) => {
if (id !== clientId && client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify({
type: 'user-joined',
documentId,
clientId
}));
}
});
break;
}
case 'operation': {
const { documentId, operation, clientVersion } = data;
const doc = documents.get(documentId);
if (!doc) {
ws.send(JSON.stringify({
type: 'error',
message: 'Document not found'
}));
return;
}
// Transform operation against all operations since client's version
let transformedOp = { ...operation };
for (let i = clientVersion; i < doc.operations.length; i++) {
transformedOp = transform(transformedOp, doc.operations[i]);
}
// Apply the transformed operation
if (transformedOp.type === 'insert') {
doc.content =
doc.content.slice(0, transformedOp.position) +
transformedOp.text +
doc.content.slice(transformedOp.position);
} else if (transformedOp.type === 'delete') {
doc.content =
doc.content.slice(0, transformedOp.position) +
doc.content.slice(transformedOp.position + transformedOp.length);
}
doc.operations.push(transformedOp);
doc.version++;
// Broadcast to all clients
const broadcastMessage = JSON.stringify({
type: 'operation',
documentId,
operation: transformedOp,
version: doc.version,
clientId
});
clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(broadcastMessage);
}
});
break;
}
case 'ping': {
ws.send(JSON.stringify({ type: 'pong' }));
break;
}
}
});
ws.on('close', () => {
clients.delete(clientId);
clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify({
type: 'user-left',
clientId
}));
}
});
});
ws.on('error', (error) => {
console.error('WebSocket error:', error);
clients.delete(clientId);
});
});
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
Step 3: Create the HTML Interface
Create public/index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Collaborative Code Editor</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.13/codemirror.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.13/theme/dracula.min.css">
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<header>
<h1>🚀 Collaborative Code Editor</h1>
<div class="controls">
<input type="text" id="documentId" placeholder="Enter document ID" value="shared-doc-1">
<button id="joinBtn">Join Document</button>
</div>
<div class="status">
<span id="connectionStatus">Disconnected</span>
<span id="userCount">Users: 0</span>
</div>
</header>
<main>
<div class="editor-container">
<textarea id="editor"></textarea>
</div>
</main>
<footer>
<div class="info">
<p>Share the document ID with others to collaborate in real-time</p>
<p>Changes sync automatically across all connected clients</p>
</div>
</footer>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.13/codemirror.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.13/mode/javascript/javascript.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.13/mode/xml/xml.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.13/mode/css/css.min.js"></script>
<script src="client.js"></script>
</body>
</html>
Step 4: Style the Editor
Create public/style.css:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: #1e1e2e;
color: #cdd6f4;
min-height: 100vh;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
display: flex;
flex-direction: column;
height: 100vh;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
background: #313244;
border-radius: 12px;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 15px;
}
h1 {
font-size: 1.8rem;
color: #f5c2e7;
}
.controls {
display: flex;
gap: 10px;
}
#documentId {
padding: 10px 15px;
border: 2px solid #45475a;
border-radius: 8px;
background: #181825;
color: #cdd6f4;
font-size: 1rem;
width: 250px;
}
#documentId:focus {
outline: none;
border-color: #89b4fa;
}
#joinBtn {
padding: 10px 20px;
background: #89b4fa;
color: #1e1e2e;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
#joinBtn:hover {
background: #b4befe;
transform: translateY(-2px);
}
#joinBtn:disabled {
background: #45475a;
cursor: not-allowed;
transform: none;
}
.status {
display: flex;
gap: 20px;
font-size: 0.9rem;
}
#connectionStatus {
padding: 5px 12px;
border-radius: 6px;
background: #45475a;
}
#connectionStatus.connected {
background: #a6e3a1;
color: #1e1e2e;
}
#userCount {
color: #bac2de;
}
main {
flex: 1;
display: flex;
overflow: hidden;
}
.editor-container {
flex: 1;
border-radius: 12px;
overflow: hidden;
border: 2px solid #45475a;
}
.CodeMirror {
height: 100% !important;
font-family: 'Fira Code', 'Courier New', monospace;
font-size: 14px;
line-height: 1.6;
}
footer {
margin-top: 20px;
padding: 15px;
background: #313244;
border-radius: 12px;
text-align: center;
}
.info p {
margin: 5px 0;
color: #bac2de;
font-size: 0.9rem;
}
Step 5: Build the Client-Side Logic
Create public/client.js:
let ws = null;
let clientId = null;
let currentDocumentId = null;
let currentVersion = 0;
let editor = null;
let isLocalChange = false;
let userCount = 0;
// Initialize CodeMirror editor
function initEditor() {
const textarea = document.getElementById('editor');
editor = CodeMirror.fromTextArea(textarea, {
mode: 'javascript',
theme: 'dracula',
lineNumbers: true,
autoCloseBrackets: true,
matchBrackets: true,
indentUnit: 2,
tabSize: 2,
indentWithTabs: false,
lineWrapping: true
});
editor.on('changes', handleEditorChanges);
}
// Handle editor changes and send operations to server
function handleEditorChanges(instance, changes) {
if (isLocalChange || !ws || ws.readyState !== WebSocket.OPEN) {
isLocalChange = false;
return;
}
changes.forEach(change => {
if (change.origin === '+input' || change.origin === '+delete') {
const operation = createOperation(change);
if (operation) {
sendOperation(operation);
}
}
});
}
// Create operation object from editor change
function createOperation(change) {
const from = change.from;
const to = change.to;
const position = from.line * 1000 + from.ch; // Simple position encoding
if (change.text && change.text.length > 0 && change.text !== '') {
// Insert operation
const text = change.text.join('\n');
return {
type: 'insert',
position: position,
text: text,
timestamp: Date.now()
};
} else if (change.removed && change.removed.length > 0) {
// Delete operation
const removed = change.removed.join('\n');
return {
type: 'delete',
position: position,
length: removed.length,
timestamp: Date.now()
};
}
return null;
}
// Send operation to server
function sendOperation(operation) {
const message = {
type: 'operation',
documentId: currentDocumentId,
operation: operation,
clientVersion: currentVersion
};
ws.send(JSON.stringify(message));
}
// Connect to WebSocket server
function connectToWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}`;
ws = new WebSocket(wsUrl);
ws.onopen = () => {
updateConnectionStatus(true);
console.log('Connected to server');
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
handleMessage(data);
};
ws.onclose = () => {
updateConnectionStatus(false);
console.log('Disconnected from server');
setTimeout(connectToWebSocket, 3000); // Reconnect after 3 seconds
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
}
// Handle incoming messages
function handleMessage(data) {
switch (data.type) {
case 'joined':
handleJoinResponse(data);
break;
case 'operation':
handleRemoteOperation(data);
break;
case 'user-joined':
userCount++;
updateUserCount();
break;
case 'user-left':
userCount = Math.max(0, userCount - 1);
updateUserCount();
break;
case 'pong':
// Heartbeat response
break;
case 'error':
console.error('Server error:', data.message);
break;
}
}
// Handle successful document join
function handleJoinResponse(data) {
clientId = data.clientId;
currentDocumentId = data.documentId;
currentVersion = data.version;
editor.setValue(data.content);
editor.refresh();
document.getElementById('joinBtn').disabled = true;
document.getElementById('documentId').disabled = true;
console.log(`Joined document ${data.documentId}, version ${data.version}`);
}
// Handle remote operation from another client
function handleRemoteOperation(data) {
if (data.clientId === clientId) return; // Ignore own operations
const operation = data.operation;
isLocalChange = true;
if (operation.type === 'insert') {
insertTextAtPosition(operation.position, operation.text);
} else if (operation.type === 'delete') {
deleteTextAtPosition(operation.position, operation.length);
}
currentVersion = data.version;
isLocalChange = false;
}
// Insert text at specific position
function insertTextAtPosition(position, text) {
const doc = editor.getDoc();
const line = Math.floor(position / 1000);
const ch = position % 1000;
const cursor = doc.getCursor();
doc.replaceRange(text, { line, ch });
// Restore cursor position
doc.setCursor(cursor);
}
// Delete text at specific position
function deleteTextAtPosition(position, length) {
const doc = editor.getDoc();
const startLine = Math.floor(position / 1000);
const startCh = position % 1000;
let remainingLength = length;
let endLine = startLine;
let endCh = startCh;
// Find the end position
while (remainingLength > 0) {
const lineText = doc.getLine(endLine);
const availableInLine = lineText.length - endCh;
if (remainingLength <= availableInLine) {
endCh += remainingLength;
remainingLength = 0;
} else {
remainingLength -= availableInLine + 1; // +1 for newline
endCh = 0;
endLine++;
}
}
doc.replaceRange('', { line: startLine, ch: startCh }, { line: endLine, ch: endCh });
}
// Update connection status display
function updateConnectionStatus(connected) {
const statusEl = document.getElementById('connectionStatus');
if (connected) {
statusEl.textContent = 'Connected';
statusEl.classList.add('connected');
} else {
statusEl.textContent = 'Disconnected';
statusEl.classList.remove('connected');
}
}
// Update user count display
function updateUserCount() {
document.getElementById('userCount').textContent = `Users: ${userCount}`;
}
// Join document
function joinDocument() {
const documentId = document.getElementById('documentId').value.trim();
if (!documentId) {
alert('Please enter a document ID');
return;
}
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'join',
documentId: documentId
}));
}
}
// Initialize when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
initEditor();
connectToWebSocket();
document.getElementById('joinBtn').addEventListener('click', joinDocument);
// Allow Enter key to join
document.getElementById('documentId').addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
joinDocument();
}
});
// Heartbeat to keep connection alive
setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }));
}
}, 30000);
});
Step 6: Run and Test Your Editor
Start the server:
node server.js
Or with auto-reload:
npx nodemon server.js
Open your browser to http://localhost:3000.
Testing Collaboration
- Open
http://localhost:3000in two different browser windows (or use incognito mode) - Enter the same document ID (e.g.,
shared-doc-1) in both windows - Click "Join Document" in both windows
- Start typing in one window - you'll see changes appear in real-time in the other window
- Try typing simultaneously in both windows to see operational transformation resolve conflicts
How Operational Transformation Works
The core magic happens in the transform() function. Here's what it does:
| Scenario | What Happens |
|---|---|
| Two insertions at different positions | The later insertion's position is adjusted based on the length of the first insertion |
| Insertion after deletion | Insertion position is adjusted if it was after the deleted text |
| Overlapping operations | Operations are transformed to maintain document consistency |
The algorithm ensures that regardless of the order operations arrive, all clients converge to the same document state.
Key Concepts You Learned
- WebSocket Communication: Full-duplex communication enabling real-time updates [server.js]
- Operational Transformation: Conflict resolution algorithm for collaborative editing [transform function]
- Distributed State Management: Keeping multiple clients synchronized [handleRemoteOperation]
- Editor Integration: Using CodeMirror for syntax highlighting and editor features [initEditor]
Next Steps to Extend This Project
- Add user cursors: Show other users' cursors with their names/colors
- Implement history/undo: Track operation history for undo functionality
- Add authentication: Require login before joining documents
- Persistent storage: Save documents to a database instead of memory
- Support more languages: Add syntax highlighting for Python, HTML, CSS, etc.
- Add chat: Include a sidebar chat for communication while editing
Common Pitfalls and Solutions
Problem: Operations arrive out of order
Solution: The transformation algorithm handles this by transforming against all operations since the client's version [handleRemoteOperation]
Problem: Cursor jumps around during collaboration
Solution: Save and restore cursor position after applying remote operations [insertTextAtPosition]
Problem: Multiple rapid changes create too many operations
Solution: Batch changes within a debounce interval (currently handled by CodeMirror's changes event) [handleEditorChanges]
Complete Working Example
You now have a fully functional collaborative code editor that:
- ✅ Supports real-time collaboration with multiple users
- ✅ Resolves conflicts using operational transformation
- ✅ Provides syntax highlighting with CodeMirror
- ✅ Shows connection status and user count
- ✅ Automatically reconnects if connection is lost
The complete source code is ready to run with node server.js and opens at http://localhost:3000.
This tutorial taught you the fundamentals of building real-time collaborative applications - a skill increasingly valuable as remote work and pair programming become standard practices in software engineering.
Rizwan Saleem — https://rizwansaleem.co
Sources
- http://localhost:${PORT}`);
- https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.13/codemirror.min.css">
- https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.13/theme/dracula.min.css">
- https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.13/codemirror.min.js">
- https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.13/mode/javascript/javascript.min.js">
Top comments (0)