All posts
4 Sep 2026

Real-Time Collaboration in Node.js: Building a Document Editor with Yjs and WebSockets

{

{ “title”: “Real-Time Collaboration in Node.js: Building a Document Editor with Yjs and WebSockets”, “summary”: “A practical, code-heavy architectural guide on setting up a multi-user collaborative document backend in Node.js using Yjs, WebSockets, and LevelDB persistence.”, “tags”: [“Node.js”, “WebSockets”, “Distributed Systems”, “Backend”, “Software Architecture”], “body”: “# Real-Time Collaboration in Node.js: Building a Document Editor with Yjs and WebSockets\n\nBuilding a real-time collaborative application—like a mini Google Docs—traditionally involves complex Operational Transformation (OT) algorithms or fragile last-write-wins database strategies. In modern distributed systems, Conflict-Free Replicated Data Types (CRDTs) have emerged as the gold standard for state synchronization.\n\nIn this architectural and practical guide, we will build a robust, production-ready real-time collaborative document backend using Node.js, WebSockets (via ws), Yjs (a high-performance CRDT framework), and LevelDB for durable persistence.\n\n—\n\n## Architectural Overview\n\nUnlike traditional client-server architectures where the server dictates the single source of truth, a CRDT-based architecture treats every client as a peer holding a valid, evolving state. The Node.js backend acts primarily as a relay server and a persistence layer, rather than a central arbiter of conflicts.\n\n### Core Components:\n1. Yjs Document (Y.Doc): The underlying data structure maintaining the shared state (text, maps, arrays).\n2. WebSocket Server: Facilitates low-latency binary message exchange between clients and the server.\n3. Persistence Layer (LevelDB): Periodically or incrementally saves the binary state update vectors to disk so documents survive server restarts.\n4. Awareness Protocol: Manages ephemeral states like cursor positions, active users, and typing indicators.\n\n\n+-------------+ WebSocket +-------------------------+ LevelDB\n| Client A | <-----------------------> | | <-----> (Disk Storage)\n+-------------+ | Node.js Backend | \n | (Yjs + ws) | \n+-------------+ WebSocket | | \n| Client B | <-----------------------> | | \n+-------------+ +-------------------------+\n\n\n—\n\n## Project Setup and Dependencies\n\nLet’s initialize our Node.js project and install the required packages. We’ll need yjs for CRDT operations, ws for WebSocket communication, and level for persistence.\n\nbash\nmkdir yjs-collab-backend\ncd yjs-collab-backend\nnpm init -y\nnpm install yjs ws level\nnpm install --save-dev nodemon\n\n\nEnsure your package.json has "type": "module" enabled to use ES Modules, which Yjs heavily relies on.\n\njson\n{\n \"name\": \"yjs-collab-backend\",\n \"version\": \"1.0.0\",\n \"type\": \"module\",\n \"dependencies\": {\n \"level\": \"^8.0.0\",\n \"ws\": \"^8.16.0\",\n \"yjs\": \"^13.6.8\"\n }\n}\n\n\n—\n\n## Step 1: Document Management and Persistence\n\nTo ensure we don’t lose data when the Node.js process restarts, we need to bind our Yjs documents to a persistent store. We will use LevelDB to store document binary updates keyed by document ID.\n\nCreate a file named persistence.js:\n\njavascript\nimport { Level } from 'level';\nimport * as Y from 'yjs';\n\n// Initialize LevelDB database\nconst db = new Level('./document-store', { valueEncoding: 'binary' });\n\n/**\n * Loads a Y.Doc from LevelDB by applying all stored updates.\n * @param {string} docName \n * @returns {Promise<Y.Doc>}\n */\nexport async function getYDoc(docName) {\n const doc = new Y.Doc();\n const updates = [];\n\n try {\n // Fetch all update chunks stored for this document\n for await (const [key, value] of db.iterator({ gte: `${docName}:`, lte: `${docName}:\xff` })) {\n updates.push(Buffer.from(value));\n }\n\n if (updates.length > 0) {\n Y.transact(doc, () => {\n for (const update of updates) {\n Y.applyUpdate(doc, update);\n }\n });\n console.log(`[Persistence] Loaded doc '${docName}' from ${updates.length} update chunks.`);\n }\n } catch (err) {\n console.error(`[Persistence] Error loading doc '${docName}':`, err);\n }\n\n // Listen to future updates on the doc and persist them\n let counter = updates.length;\n doc.on('update', async (update) => {\n try {\n const key = `${docName}:${Date.now()}_${counter++}`;\n await db.put(key, Buffer.from(update));\n } catch (err) {\n console.error(`[Persistence] Error saving update for doc '${docName}':`, err);\n }\n });\n\n return doc;\n}\n\n\n—\n\n## Step 2: Building the WebSocket Server & Sync Protocol\n\nYjs uses a highly optimized binary sync protocol. When a client connects, the server and client exchange state vectors (Y.encodeStateVector) and missing updates (Y.encodeStateAsUpdate). \n\nCreate server.js to manage WebSocket connections, document routing, and message broadcasting:\n\njavascript\nimport { createServer } from 'http';\nimport { WebSocketServer } from 'ws';\nimport * as Y from 'yjs';\nimport { getYDoc } from './persistence.js';\n\nconst server = createServer();\nconst wss = new WebSocketServer({ noServer: true });\n\n// Map to store active documents in memory: docName -> { doc, set of connections }\nconst docs = new Map();\n\nasync function getCachedDoc(docName) {\n if (!docs.has(docName)) {\n const doc = await getYDoc(docName);\n const conns = new Set();\n docs.set(docName, { doc, conns });\n }\n return docs.get(docName);\n}\n\n// Yjs Protocol message constants\nconst messageSync = 0;\nconst messageAwareness = 1;\n\nwss.on('connection', async (conn, req, docName) => {\n console.log(`[WebSocket] Client connected to document: ${docName}`);\n const { doc, conns } = await getCachedDoc(docName);\n conns.add(conn);\n\n // Send sync step 1: Server sends its state vector to the client\n const encoder = Y.encodeMessage((encoder) => {\n Y.writeSyncStep1(encoder, doc);\n });\n conn.send(encoder);\n\n conn.on('message', (message) => {\n try {\n const uint8Array = new Uint8Array(message);\n const decoder = Y.createDecoder(uint8Array);\n const messageType = Y.readVarUint(decoder);\n\n switch (messageType) {\n case messageSync:\n {\n const syncEncoder = Y.createEncoder();\n Y.readSyncMessage(decoder, syncEncoder, doc, conn);\n if (Y.length(syncEncoder) > 0) {\n conn.send(Y.toUint8Array(syncEncoder));\n }\n }\n break;\n case messageAwareness:\n // Broadcast awareness (cursors, user details) to all other peers\n const awarenessUpdate = Y.readVarUint8Array(decoder);\n conns.forEach((client) => {\n if (client !== conn && client.readyState === WebSocket.OPEN) {\n const awEncoder = Y.createEncoder();\n Y.writeVarUint(awEncoder, messageAwareness);\n Y.writeVarUint8Array(awEncoder, awarenessUpdate);\n client.send(Y.toUint8Array(awEncoder));\n }\n });\n break;\n default:\n console.error(`[WebSocket] Unknown message type: ${messageType}`);\n }\n } catch (err) {\n console.error('[WebSocket] Message processing error:', err);\n }\n });\n\n // Broadcast local document updates to all connected peers\n const updateHandler = (update, origin) => {\n if (origin !== conn) {\n const encoder = Y.encodeMessage((encoder) => {\n Y.writeUpdate(encoder, update);\n });\n if (conn.readyState === WebSocket.OPEN) {\n conn.send(encoder);\n }\n }\n };\n\n doc.on('update', updateHandler);\n\n conn.on('close', () => {\n console.log(`[WebSocket] Client disconnected from document: ${docName}`);\n conns.delete(conn);\n doc.off('update', updateHandler);\n\n // Clean up empty documents from memory after a timeout if needed\n if (conns.size === 0) {\n // Optional: docs.delete(docName);\n }\n });\n});\n\n// Handle HTTP Upgrade for WebSockets based on URL path (/doc/:docName)\nserver.on('upgrade', (request, socket, head) => {\n const url = new URL(request.url, `http://${request.headers.host}`);\n const docName = url.pathname.slice(1) || 'default-document';\n\n wss.handleUpgrade(request, socket, head, (conn) => {\n wss.emit('connection', conn, request, docName);\n });\n});\n\nconst PORT = process.env.PORT || 1234;\nserver.listen(PORT, () => {\n console.log(`\n🚀 Yjs Collaboration Server running on ws://localhost:${PORT}`);\n});\n\n\n> Note: To make the above helper methods cleanly compatible with the standard Yjs wire protocol without reinventing the wheel, let’s look at how helper serialization is typically implemented in robust Yjs providers.\n\n—\n\n## Step 3: Streamlining Yjs Binary Protocol Helpers\n\nTo ensure our WebSocket frames serialize and deserialize correctly according to the Yjs protocol specs, create a small utility file y-protocol-helpers.js:\n\njavascript\nimport * as Y from 'yjs';\n\nexport const messageSync = 0;\nexport const messageAwareness = 1;\n\nexport function readSyncMessage(decoder, encoder, doc, transactionOrigin) {\n const messageType = Y.readVarUint(decoder);\n switch (messageType) {\n case 0: { // SyncStep1\n const stateVector = Y.readVarUint8Array(decoder);\n Y.writeSyncStep2(encoder, doc, stateVector);\n break;\n }\n case 1: { // SyncStep2\n const update = Y.readVarUint8Array(decoder);\n Y.applyUpdate(doc, update, transactionOrigin);\n break;\n }\n case 2: { // Update\n const update = Y.readVarUint8Array(decoder);\n Y.applyUpdate(doc, update, transactionOrigin);\n break;\n }\n default:\n throw new Error(`Unknown sync message type: ${messageType}`);\n }\n}\n\n\nUpdate your server.js switch statement to leverage proper decoding pipelines:\n\njavascript\nimport { createServer } from 'http';\nimport { WebSocketServer, WebSocket } from 'ws';\nimport * as Y from 'yjs';\nimport * encoding from 'lib0/encoding';\nimport * decoding from 'lib0/decoding';\nimport { getYDoc } from './persistence.js';\n\nconst server = createServer();\nconst wss = new WebSocketServer({ noServer: true });\nconst docs = new Map();\n\nconst messageSync = 0;\nconst messageAwareness = 1;\n\nasync function getCachedDoc(docName) {\n if (!docs.has(docName)) {\n const doc = await getYDoc(docName);\n const conns = new Set();\n docs.set(docName, { doc, conns });\n }\n return docs.get(docName);\n}\n\nwss.on('connection', async (conn, req, docName) => {\n const { doc, conns } = await getCachedDoc(docName);\n conns.add(conn);\n\n // Send Sync Step 1\n const encoder = encoding.createEncoder();\n encoding.writeVarUint(encoder, messageSync);\n Y.writeSyncStep1(encoder, doc);\n conn.send(encoding.toUint8Array(encoder));\n\n conn.on('message', (message) => {\n try {\n const decoder = decoding.createDecoder(new Uint8Array(message));\n const messageType = decoding.readVarUint(decoder);\n\n switch (messageType) {\n case messageSync: {\n const syncEncoder = encoding.createEncoder();\n encoding.writeVarUint(syncEncoder, messageSync);\n Y.readSyncMessage(decoder, syncEncoder, doc, conn);\n if (encoding.length(syncEncoder) > 1) {\n conn.send(encoding.toUint8Array(syncEncoder));\n }\n break;\n }\n case messageAwareness: {\n const awarenessUpdate = decoding.readVarUint8Array(decoder);\n conns.forEach((client) => {\n if (client !== conn && client.readyState === WebSocket.OPEN) {\n const awEncoder = encoding.createEncoder();\n encoding.writeVarUint(awEncoder, messageAwareness);\n encoding.writeVarUint8Array(awEncoder, awarenessUpdate);\n client.send(encoding.toUint8Array(awEncoder));\n }\n });\n break;\n }\n }\n } catch (err) {\n console.error('Error handling WebSocket message:', err);\n }\n });\n\n const updateHandler = (update, origin) => {\n if (origin !== conn) {\n const encoder = encoding.createEncoder();\n encoding.writeVarUint(encoder, messageSync);\n Y.writeUpdate(encoder, update);\n if (conn.readyState === WebSocket.OPEN) {\n conn.send(encoding.toUint8Array(encoder));\n }\n }\n };\n\n doc.on('update', updateHandler);\n\n conn.on('close', () => {\n conns.delete(conn);\n doc.off('update', updateHandler);\n });\n});\n\nserver.on('upgrade', (request, socket, head) => {\n const url = new URL(request.url, `http://${request.headers.host}`);\n const docName = url.pathname.slice(1) || 'default';\n wss.handleUpgrade(request, socket, head, (conn) => {\n wss.emit('connection', conn, request, docName);\n });\n});\n\nserver.listen(1234, () => {\n console.log('Collaboration backend active on ws://localhost:1234');\n});\n\n\n—\n\n## Step 4: Connecting the Frontend Client\n\nTo test our backend, let’s write a minimal client integration using y-websocket and quill or standard DOM bindings. In your frontend project, install yjs and y-websocket:\n\nbash\nnpm install yjs y-websocket\n\n\nClient implementation script (client.js or inside an HTML file):\n\njavascript\nimport * as Y from 'yjs';\nimport { WebsocketProvider } from 'y-websocket';\n\n// 1. Initialize local CRDT document\nconst ydoc = new Y.Doc();\n\n// 2. Connect to our Node.js WebSocket server for room 'my-document-1'\nconst wsProvider = new WebsocketProvider(\n 'ws://localhost:1234',\n 'my-document-1',\n ydoc\n);\n\nwsProvider.on('status', event => {\n console.log('Connection status:', event.status); // 'connected' | 'disconnected'\n});\n\n// 3. Bind to a shared text type\nconst ytext = ydoc.getText('codemirror');\n\nytext.observe(event => {\n console.log('Document updated. New content:', ytext.toString());\n});\n\n// Simulate user typing after connection\nsetTimeout(() => {\n ydoc.transact(() => {\n ytext.insert(0, 'Hello, distributed real-time world!');\n });\n}, 1000);\n\n\n—\n\n## Handling Offline Edits and Network Partitions\n\nOne of the greatest advantages of using Yjs with a persistent backend like LevelDB is how gracefully it handles offline scenarios:\n\n1. Client Goes Offline: The client continues editing locally using Y.Doc. All local mutations generate discrete binary update vectors stored in local memory/IndexedDB.\n2. Reconnection: When the WebSocket connection is re-established, y-websocket initiates the Yjs sync protocol. \n3. Vector Clock Reconciliation: The client sends its state vector to the Node.js server. The server compares vector clocks and transmits only the updates the client missed, while simultaneously ingesting the offline changes accumulated by the client. Zero data loss, zero merge conflicts.\n\n\n+-------------+ Offline Edits +-------------------+\n| Client | -------------------------------> | Local IndexedDB |\n+-------------+ +-------------------+\n |\n | Reconnects & sends State Vector\n v\n+---------------------------------------------------------------------+\n| Node.js Backend |\n| (Compares Vector Clocks & Syncs Missing Updates) |\n+---------------------------------------------------------------------\n\n\n—\n\n## Production Hardening Best Practices\n\nWhen taking a Yjs Node.js backend to production, consider the following architectural adjustments:\n\n* Garbage Collection & Compaction: Over time, accumulating thousands of micro-updates in LevelDB can bloat storage and slow down initial document load times. Implement a periodic compaction job that merges historical updates into a single snapshot update using Y.encodeStateAsUpdate(doc).:\n javascript\n const fullState = Y.encodeStateAsUpdate(doc);\n await db.clear();\n await db.put(`${docName}:snapshot`, Buffer.from(fullState));\n \n* Horizontal Scaling (Redis Pub/Sub): If you run multiple Node.js instances behind a load balancer, two collaborating users might connect to different server instances. Use Redis Pub/Sub to broadcast Yjs binary updates across your cluster nodes so all server instances keep their in-memory Y.Doc instances synchronized.\n* Authentication and Authorization: Intercept the WebSocket upgrade event or handle auth messages immediately upon connection opening to validate JSON Web Tokens (JWT) before assigning clients to specific document rooms.\n\n—\n\n## Conclusion\n\nBy pairing Node.js with Yjs and LevelDB, you eliminate the massive engineering overhead of building custom conflict resolution algorithms. CRDTs guarantee eventual consistency mathematically, allowing your backend to act as a lightweight, lightning-fast relay and storage engine while empowering users to edit seamlessly—even across network drops and offline sessions.” }

More posts