Building a Real-Time Collaborative Editor with WebSockets and CRDTs
A comprehensive guide to constructing a Google Docs-like multi-user real-time collaborative editor using Node.js, WebSockets, and Yjs CRDTs.

Building a real-time collaborative editor like Google Docs or Figma is one of the most complex challenges in web development. When multiple users type, highlight, and format text simultaneously, keeping their browser states in sync without losing user inputs or duplicating characters is extremely difficult.
Historically, developers solved this using Operational Transformation (OT), which requires a centralized server to sequence and transform editing commands. However, modern applications are shifting toward Conflict-Free Replicated Data Types (CRDTs). CRDTs are data structures that can be updated independently and concurrently without coordination, guaranteed to converge mathematically to the exact same state.
In this tutorial, we will build a real-time collaborative text editor from scratch. We will use Node.js and WebSockets for the transport layer, Yjs (a high-performance CRDT library) for state resolution, and Quill for the rich-text editor frontend.
OT vs CRDT: Why Choose CRDTs?
graph TD
subgraph Operational Transformation
A[Client 1] -->|Operation A| Server[Central Server]
B[Client 2] -->|Operation B| Server
Server -->|Transforms & Sequences| A
Server -->|Transforms & Sequences| B
end
subgraph CRDT
C[Client 1] -->|State Update C| Transport[WebSocket Broker]
D[Client 2] -->|State Update D| Transport
Transport -->|Broadcasts Updates| C
Transport -->|Broadcasts Updates| D
Note over C, D: Both clients merge states locally. Convergence is guaranteed!
end
- Operational Transformation (OT): Requires a smart server to resolve conflicts. If the server goes offline or has a network lag, operations queue up, causing latency and complicated rollback loops.
- CRDT (Yjs): Conflict resolution is handled mathematically on the client side. The server acts as a simple, stateless broker broadcasting update packets. This architecture is perfect for local-first apps, offline editing, and peer-to-peer networks.
Prerequisites & Dependencies
To set this up, create a folder for your backend websocket server, and a separate React or Next.js app for your frontend client.
Install dependencies for the Node.js backend server:
npm install ws yjs y-websocket
Install dependencies for your React client app:
npm install yjs y-websocket y-quill quill
yjs: The core CRDT document engine.y-websocket: Handles WebSocket synchronizations between the client and server.y-quill: Binds the Yjs shared text structure to the Quill editor instance.quill: A modern rich-text editor with clean API bindings.
Step 1: Building the WebSocket Backend Server
Our backend server is simple: it handles client socket connections, spins up a Yjs document instance for each active room, and broadcasts changes to all other connected peers editing the same document.
Create server.js for the Node.js backend:
const WebSocket = require("ws");
const http = require("http");
const { setupWSConnection } = require("y-websocket/bin/utils");
// Set up server port
const port = process.env.PORT || 1234;
const server = http.createServer((request, response) => {
response.writeHead(200, { "Content-Type": "text/plain" });
response.end("Yjs WebSocket Broker running.");
});
const wss = new WebSocket.Server({ noServer: true });
// Handle upgrade from HTTP to WebSocket protocols
server.on("upgrade", (request, socket, head) => {
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit("connection", ws, request);
});
});
// Setup Yjs WebSockets connection handler
wss.on("connection", (ws, request) => {
// setupWSConnection manages document syncing, state storage, and broadcasting updates
setupWSConnection(ws, request, {
gc: true, // Enable garbage collection of historical actions
});
console.log("New peer connected successfully");
});
server.listen(port, () => {
console.log(`WebSocket broker listening on port ${port}`);
});
Run this backend script using node server.js. It will act as the data bridge between our collaborating editors.
Step 2: Creating the Collaborative Client Editor
Now, let's build the React client page. We will initialize a shared Y.Doc, connect to our websocket server, bind it to a local Quill editor instance, and track active users (user cursors) using Yjs's Awareness API.
Create a component named app/components/CollabEditor.tsx:
"use client";
import { useEffect, useRef, useState } from "react";
import * as Y from "yjs";
import { WebsocketProvider } from "y-websocket";
import { QuillBinding } from "y-quill";
import { Users } from "lucide-react";
import "quill/dist/quill.snow.css";
export default function CollabEditor() {
const editorRef = useRef<HTMLDivElement>(null);
const [activeUsers, setActiveUsers] = useState<number>(1);
const [isConnected, setIsConnected] = useState<boolean>(false);
useEffect(() => {
if (!editorRef.current) return;
// 1. Initialize Quill editor
// We import Quill dynamically to ensure it runs exclusively in client-side environment
const Quill = require("quill");
const quillInstance = new Quill(editorRef.current, {
theme: "snow",
placeholder: "Write something collaborative here...",
modules: {
toolbar: [
[{ header: [1, 2, 3, false] }],
["bold", "italic", "underline", "strike"],
[{ list: "ordered" }, { list: "bullet" }],
["code-block"],
],
},
});
// 2. Instantiate Yjs Document (CRDT State)
const ydoc = new Y.Doc();
// 3. Connect to WebSocket Provider
const provider = new WebsocketProvider(
"ws://localhost:1234", // Target URL of our backend server
"collab-demo-room", // Unique room / document identifier
ydoc
);
// Track network connection status
provider.on("status", (event: { status: string }) => {
setIsConnected(event.status === "connected");
});
// 4. Bind Yjs Text to Quill Editor
const ytext = ydoc.getText("quill-content");
const binding = new QuillBinding(ytext, quillInstance, provider.awareness);
// 5. Setup Awareness (User cursor tracking & cursor labels)
// Generate a random name/color for the user
const colors = ["#2dd4bf", "#3b82f6", "#ef4444", "#eab308", "#a855f7"];
const names = ["Alice", "Bob", "Charlie", "David", "Eve"];
const randomColor = colors[Math.floor(Math.random() * colors.length)];
const randomName = names[Math.floor(Math.random() * names.length)];
provider.awareness.setLocalStateField("user", {
name: randomName,
color: randomColor,
});
// Update active user count when peer connections change
provider.awareness.on("change", () => {
setActiveUsers(provider.awareness.getStates().size);
});
// Clean up connections on unmount
return () => {
binding.destroy();
provider.disconnect();
ydoc.destroy();
};
}, []);
return (
<div className="max-w-4xl mx-auto p-6 space-y-4">
{/* Document Meta Header */}
<div className="flex items-center justify-between bg-slate-900 border border-slate-800 p-4 rounded-xl">
<div className="space-y-1">
<h2 className="text-lg font-bold text-white tracking-tight">Design Specs Draft</h2>
<div className="flex items-center gap-2">
<span
className={`w-2.5 h-2.5 rounded-full ${
isConnected ? "bg-emerald-500 animate-pulse" : "bg-red-500"
}`}
></span>
<span className="text-xs text-slate-400 font-mono">
{isConnected ? "Synced with server" : "Offline / Reconnecting"}
</span>
</div>
</div>
<div className="flex items-center gap-2 px-3 py-1.5 bg-slate-800 rounded-lg text-teal-400 text-xs font-semibold">
<Users className="w-4 h-4" />
<span>{activeUsers} active editor{activeUsers !== 1 && "s"}</span>
</div>
</div>
{/* Quill Canvas Container */}
<div className="bg-slate-900 border border-slate-800 rounded-xl overflow-hidden shadow-2xl">
<div ref={editorRef} className="text-slate-100 min-h-[400px]"></div>
</div>
</div>
);
}
Step 3: Injecting Collaborative Cursor CSS Styles
Quill binding requires styles to overlay user cursors. Add the following CSS rules to your global stylesheet (app/globals.css):
/* Quill Editor Base Styling Customization */
.ql-toolbar.ql-snow {
background-color: #0f172a !important;
border-color: #1e293b !important;
border-top-left-radius: 12px;
border-top-right-radius: 12px;
}
.ql-container.ql-snow {
border-color: #1e293b !important;
font-family: inherit !important;
font-size: 14px !important;
border-bottom-left-radius: 12px;
border-bottom-right-radius: 12px;
}
.ql-editor {
color: #cbd5e1 !important;
min-height: 380px;
}
/* Yjs Collaborative Cursor Visual Representation */
.yRemoteSelection {
background-color: rgba(45, 212, 191, 0.2);
}
.yRemoteSelectionHead {
position: absolute;
border-left: 2px solid;
border-color: inherit;
height: 100%;
box-sizing: border-box;
}
.yRemoteSelectionHead::after {
position: absolute;
content: attr(data-username);
top: -12px;
left: -2px;
font-size: 9px;
font-family: sans-serif;
color: white;
padding: 1px 4px;
border-radius: 2px;
white-space: nowrap;
background-color: inherit;
pointer-events: none;
font-weight: 600;
}
Production Database Persistence
By default, our WebSocket broker stores documents purely in memory. If the server crashes, all editing history is lost. To persist updates to a database (such as MongoDB, PostgreSQL, or S3):
- Listen to Document Updates: In your server script, listen to Yjs document mutations:
ydoc.on("update", (update) => { // Store binary update payload in database saveDocumentUpdateToDB(roomId, update); }); - Retrieve on Setup: When the first client connects to a room, load the historical binary update package from the database and feed it back to the
ydoc:const historicalUpdates = await getDocumentFromDB(roomId); Y.applyUpdate(ydoc, historicalUpdates);
Conclusion
You have constructed a real-time collaborative text editor using Yjs and WebSockets! By replacing classic operational transformations with Conflict-Free Replicated Data Types (CRDTs), you have built a system that handles offline-syncing, performs complex conflict resolutions on client browsers, and relies on a simple Node.js WebSocket broker.
From here, you can support richer editing blocks like formatting check-lists, nested tables, markdown integrations, or extend the socket protocol to sync Figma-like canvas vector graphs.