Building a Real-Time Collaborative Document Editor: Implementing CRDTs with Yjs and WebSockets in Node.js
A practical, code-heavy architectural guide on setting up a Yjs document provider over WebSockets in Node.js to handle conflict-free state synchronization for multi-user editing.
Building a Real-Time Collaborative Document Editor: Implementing CRDTs with Yjs and WebSockets in Node.js
Real-time collaboration is no longer a luxury feature; it is an expectation. Whether users are co-authoring text documents, adjusting spreadsheet cells, or manipulating visual canvases, they expect instantaneous synchronization akin to Google Docs or Figma.
Building this capability traditionally involves complex Operational Transformation (OT) algorithms managed by centralized servers. However, OT is notoriously difficult to implement correctly on custom backends. Enter CRDTs (Conflict-free Replicated Data Types)—a family of data structures that can be replicated across multiple nodes, updated independently and concurrently, and automatically merged without conflicts.
In this architectural and practical guide, we will build a robust backend for a real-time collaborative document editor using Node.js, WebSockets (ws), and Yjs, one of the most production-ready CRDT frameworks available today.
The Architecture: Why CRDTs and WebSockets?
Before writing code, let’s understand the data flow. In a traditional client-server architecture, the server is the single source of truth. In a CRDT-based architecture, every client holds the complete state of the document.
- Local Mutations: When a user types a character, the change is applied immediately to their local Yjs document and rendered on their screen.
- State Propagation: The Yjs client serializes the delta (the incremental update) and sends it to the server via WebSockets.
- Server Relaying & Persistence: The Node.js WebSocket server acts as a relay, broadcasting the update to all other connected clients, and optionally persists the state updates to a database.
- Automatic Merging: When remote clients receive the update, Yjs merges it deterministically using vector clocks and state vectors, guaranteeing that all replicas converge to the exact same state without central locking.
+----------+ +-------------+ +----------+
| Client A | --- WebSocket --> | | --- WebSocket --> | Client B |
+----------+ | Node.js | +----------+
| WebSocket |
+----------+ | Server | +----------+
| Client C | --- WebSocket --> | with Yjs | --- WebSocket --> | Client D |
+----------+ +-------------+ +----------+
Step 1: Project Setup and Dependencies
Let’s initialize our Node.js backend project. Create a new directory and install the required dependencies.
mkdir yjs-websocket-server
cd yjs-websocket-server
npm init -y
Install yjs for CRDT management and ws for high-performance WebSocket handling.
npm install yjs ws
npm install --save-dev typescript @types/node @types/ws tsx
Initialize TypeScript configuration:
npx tsc --init
Step 2: Designing the WebSocket Signaling and Sync Protocol
Yjs provides a standard binary protocol for synchronizing documents over network layers. To make our WebSocket server fully compatible with Yjs client providers (y-websocket), we need to handle specific message types:
- Sync Step 1: Exchange state vectors to figure out which updates the remote peer is missing.
- Sync Step 2: Send the actual missing updates.
- Update: Broadcast incremental changes made by users during editing.
Let’s write a modular Node.js WebSocket server that manages document rooms, handles binary syncing, and broadcasts updates.
Create a file named server.ts:
import http from 'http';
import { WebSocketServer, WebSocket } from 'ws';
import * as Y from 'yjs';
import * as syncProtocol from 'y-protocols/sync';
import * as encoding from 'lib0/encoding';
import * as decoding from 'lib0/decoding';
import { estabeleceConnection } from './utils';
const server = http.createServer((request, response) => {
response.writeHead(200, { 'Content-Type': 'text/plain' });
response.end('Yjs WebSocket Server is running\n');
});
const wss = new WebSocketServer({ noServer: true });
// Map to store documents by room name
const docs = new Map<string, Y.Doc>();
function getYDoc(roomName: string): Y.Doc {
let doc = docs.get(roomName);
if (!doc) {
doc = new Y.Doc();
docs.set(roomName, doc);
}
return doc;
}
// Protocol constants from y-protocols
const messageSync = 0;
const messageAwareness = 1;
wss.on('connection', (conn: WebSocket, req, roomName: string) => {
const doc = getYDoc(roomName);
// Track connections per document room
doc.conns = doc.conns || new Set<WebSocket>();
doc.conns.add(conn);
console.log(`Client connected to room: ${roomName}`);
conn.on('message', (message: ArrayBuffer) => {
try {
const encoder = encoding.createEncoder();
const decoder = decoding.createDecoder(new Uint8Array(message));
const messageType = decoding.readVarUint(decoder);
switch (messageType) {
case messageSync:
encoding.writeVarUint(encoder, messageSync);
syncProtocol.readSyncMessage(decoder, encoder, doc, conn);
if (encoding.length(encoder) > 1) {
conn.send(encoding.toUint8Array(encoder));
}
break;
case messageAwareness:
// Handle awareness (cursor positions, user presence) if needed
break;
default:
console.error(`Unknown message type: ${messageType}`);
}
} catch (err) {
console.error('Error processing message:', err);
}
});
// Broadcast local changes to all other clients in the room
const updateHandler = (update: Uint8Array, origin: any) => {
if (origin === conn) return;
const encoder = encoding.createEncoder();
encoding.writeVarUint(encoder, messageSync);
syncProtocol.writeUpdate(encoder, update);
const message = encoding.toUint8Array(encoder);
if (conn.readyState === WebSocket.OPEN) {
conn.send(message);
}
};
doc.on('update', updateHandler);
// Send initial sync step 1 to client
const encoder = encoding.createEncoder();
encoding.writeVarUint(encoder, messageSync);
syncProtocol.writeSyncStep1(encoder, doc);
conn.send(encoding.toUint8Array(encoder));
conn.on('close', () => {
doc.off('update', updateHandler);
doc.conns.delete(conn);
console.log(`Client disconnected from room: ${roomName}`);
if (doc.conns.size === 0) {
// Optional: persist to DB before deleting from memory
docs.delete(roomName);
console.log(`Room ${roomName} closed and cleared from memory.`);
}
});
});
// Handle WebSocket upgrade requests with room routing
server.on('upgrade', (request, socket, head) => {
const url = new URL(request.url || '', `http://${request.headers.host}`);
const roomName = url.pathname.slice(1) || 'default-room';
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit('connection', ws, request, roomName);
});
});
const PORT = process.env.PORT || 1234;
server.listen(PORT, () => {
console.log(`Yjs WebSocket server running on port ${PORT}`);
});
Step 3: Understanding the Yjs Sync Protocol
To ensure data integrity, the synchronization routine follows a handshake specified by y-protocols:
- State Vector Exchange: When a client connects, the server sends a Sync Step 1 message containing its State Vector (a summary of what updates the server has seen).
- Missing Update Calculation: The client compares the server’s state vector against its own history, determines which updates the server is missing, and sends them via Sync Step 2.
- Bi-directional Catchup: Conversely, the server responds with any updates the client is missing.
This guarantees that even if a client disconnects for hours, rejoins, and sends offline edits, Yjs will seamlessly reconcile the history using vector clocks.
Step 4: Connecting the Frontend Client
While our focus is the backend, it is critical to see how a client consumes this WebSocket provider. Using y-websocket on the frontend requires minimal configuration:
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import { QuillBinding } from 'y-quill'; // Or y-codemirror, y-monaco, etc.
// 1. Initialize the Yjs document
const doc = new Y.Doc();
// 2. Connect via WebSocket provider to our Node.js server
const provider = new WebsocketProvider('ws://localhost:1234', 'my-document-room', doc);
provider.on('status', event => {
console.log('Connection status:', event.status); // 'connected' or 'disconnected'
});
// 3. Bind to a rich-text editor (e.g., Quill)
const textType = doc.getText('quill');
const editorContainer = document.querySelector('#editor');
const editor = new Quill(editorContainer);
const binding = new QuillBinding(textType, editor, provider.awareness);
Step 5: Production Considerations & Persistence
An in-memory CRDT server is fast, but if the Node process restarts, all unsaved document states are lost. To make this production-ready, we must implement persistence.
Adding LevelDB or PostgreSQL Persistence
You can hook into Yjs updates to persist state increments or periodic full snapshots to a database like PostgreSQL or LevelDB.
import * as Y from 'yjs';
import Database from 'better-sqlite3';
const db = new Database('documents.db');
db.prepare(`
CREATE TABLE IF NOT EXISTS documents (
room_name TEXT PRIMARY KEY,
state BLOB
)
`).run();
function saveDocToDB(roomName: string, doc: Y.Doc) {
const state = Y.encodeStateAsUpdate(doc);
const stmt = db.prepare('INSERT OR REPLACE INTO documents (room_name, state) VALUES (?, ?)');
stmt.run(roomName, state);
}
function loadDocFromDB(roomName: string, doc: Y.Doc): Y.Doc {
const row = db.prepare('SELECT state FROM documents WHERE room_name = ?').get(roomName) as { state: Buffer } | undefined;
if (row) {
Y.applyUpdate(doc, new Uint8Array(row.state));
}
return doc;
}
Update your getYDoc function to load from the database upon initialization and set up a debounce timer to save state changes asynchronously:
function getYDoc(roomName: string): Y.Doc {
let doc = docs.get(roomName);
if (!doc) {
doc = new Y.Doc();
loadDocFromDB(roomName, doc);
// Persist updates with a debounce strategy
let timeout: NodeJS.Timeout;
doc.on('update', () => {
clearTimeout(timeout);
timeout = setTimeout(() => {
saveDocToDB(roomName, doc);
}, 2000);
});
docs.set(roomName, doc);
}
return doc;
}
Conclusion
Building a real-time collaborative editor no longer requires writing complex operational transformation resolvers. By pairing Node.js WebSockets with Yjs, you offload conflict resolution entirely to mathematically sound CRDT data structures.
Key Takeaways:
- Decentralized State: Clients handle local updates instantly; the server acts merely as a broadcast relay and persistence layer.
- Standardized Protocol: Using
y-protocolsensures your custom Node.js backend remains fully compatible with official Yjs frontend providers. - Resilient Scaling: Because documents are isolated by room names and updates are stateless byte arrays, scaling horizontally across multiple Node instances using Redis pub/sub becomes straightforward.
Now you have the foundational architecture required to build high-performance, resilient, and conflict-free collaborative applications at scale.