0306090120150180210240270300330km/h
0 km/h
ADIT JANGID

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 with WebSockets and 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):

  1. 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);
    });
    
  2. 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.

WebSocketsCRDTYjsReal-TimeCollaborative

Related Posts

Interested in working together?

Let's translate complex software and automation problems into clean, high-performance systems.