DEV Community

Wilson Xu
Wilson Xu

Posted on

Building a Real-Time Collaborative Editor with CRDTs and Yjs

Building a Real-Time Collaborative Editor with CRDTs and Yjs

Published target: Smashing Magazine | Estimated read: 15 minutes | Topics: JavaScript, WebSockets, Real-Time Apps


Introduction: The Hard Problem of Collaboration

You open a shared Google Doc with a colleague, both start typing at the same time, and somehow — magically — neither of you overwrites the other's work. Your edits appear in theirs, their edits appear in yours, and the document stays coherent even if the network flickers for a few seconds.

Behind that magic is one of the most elegant unsolved problems in distributed systems: how do you merge concurrent edits from multiple users into a single consistent document, without a central authority deciding who "wins"?

The naive answer — last write wins — destroys data. Operational transforms (OT), the approach Google Docs originally used, work but are fiendishly complex to implement correctly. For years, building collaborative features meant either using Google's infrastructure or spending months on custom OT code that was still subtly buggy.

Then Conflict-free Replicated Data Types (CRDTs) arrived as a practical alternative. In 2026, the Yjs library makes CRDTs approachable enough to integrate into any web app in an afternoon.

In this tutorial, you'll build a real-time collaborative rich-text editor from scratch using Yjs, Quill (a popular editor), and WebSockets. By the end, you'll have:

  • A working collaborative editor where multiple users can type simultaneously
  • Offline editing support with automatic sync when reconnected
  • User presence indicators showing who is currently editing
  • A solid mental model of how CRDTs work under the hood

Let's dig in.


What Are CRDTs, Actually?

A CRDT (Conflict-free Replicated Data Type) is a data structure designed so that any two replicas of it can always be merged deterministically, regardless of the order in which operations arrived.

The key insight: instead of sending "replace character at position 5 with X", CRDTs send operations with enough context that they can be applied in any order and still produce the same result.

Consider two users editing the string "Hello":

  • User A inserts "!" after "Hello" → "Hello!"
  • User B inserts " World" after "Hello" → "Hello World"

With OT, you need a central server to serialize these operations and transform them relative to each other. With a CRDT, both operations carry unique identifiers and logical timestamps. When merged:

  • A's insertion is anchored to the character immediately before the "!"
  • B's insertion is anchored to the space before "World"

The result is always "Hello World!" — both edits are preserved, and the merge is computed identically on every peer, requiring no coordination.

Yjs implements a specific CRDT called YATA (Yet Another Transformation Approach), optimized for text editing. It's battle-tested in production at companies including Atlassian, Loom, and Nimbus.


Project Setup

We'll build two things:

  1. A Node.js WebSocket server that relays Yjs document updates between clients
  2. A browser client with a Quill editor connected via Yjs

Prerequisites

  • Node.js 20+
  • Basic familiarity with WebSockets
  • npm or pnpm

Initialize the Project

mkdir collab-editor && cd collab-editor
npm init -y

# Server dependencies
npm install ws y-websocket yjs

# We'll serve the client statically
npm install -D http-server
Enter fullscreen mode Exit fullscreen mode

Create this directory structure:

collab-editor/
├── server.js
├── public/
│   ├── index.html
│   └── client.js
└── package.json
Enter fullscreen mode Exit fullscreen mode

Building the WebSocket Server

The Yjs ecosystem includes y-websocket, a ready-made WebSocket provider that handles document synchronization. All we need to write is the server bootstrap.

Create server.js:

import { WebSocketServer } from 'ws';
import http from 'http';
import * as Y from 'yjs';
import { setupWSConnection } from 'y-websocket/bin/utils.js';

// In-memory document store — use a database in production
const docs = new Map();

const server = http.createServer((req, res) => {
  // Serve a simple health check
  if (req.url === '/health') {
    res.writeHead(200);
    res.end('OK');
    return;
  }
  res.writeHead(404);
  res.end();
});

const wss = new WebSocketServer({ server });

wss.on('connection', (ws, req) => {
  // Extract the document name from the URL path
  // e.g., ws://localhost:1234/my-document
  const docName = req.url?.slice(1) || 'default';

  console.log(`Client connected to document: "${docName}"`);

  // y-websocket handles all the sync protocol for us
  setupWSConnection(ws, req, {
    docName,
    gc: true, // garbage collect deleted content
  });

  ws.on('close', () => {
    console.log(`Client disconnected from: "${docName}"`);
  });
});

const PORT = process.env.PORT || 1234;
server.listen(PORT, () => {
  console.log(`Yjs WebSocket server running on ws://localhost:${PORT}`);
});
Enter fullscreen mode Exit fullscreen mode

Add a start script to package.json:

{
  "type": "module",
  "scripts": {
    "server": "node server.js",
    "client": "http-server public -p 3000"
  }
}
Enter fullscreen mode Exit fullscreen mode

That's the entire server. setupWSConnection handles:

  • Initial document state sync when a new client connects
  • Broadcasting incremental updates to all connected peers
  • Awareness protocol (cursor positions, user presence)
  • Reconnection and message ordering

Building the Client

Now for the interesting part. We'll connect a Quill editor to a Yjs document via y-quill and y-websocket.

HTML Shell

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 Editor</title>

  <!-- Quill editor stylesheet -->
  <link href="https://cdn.quilljs.com/1.3.7/quill.snow.css" rel="stylesheet">

  <style>
    * { box-sizing: border-box; margin: 0; padding: 0; }

    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
      background: #f5f5f5;
      min-height: 100vh;
    }

    header {
      background: white;
      border-bottom: 1px solid #e0e0e0;
      padding: 12px 24px;
      display: flex;
      align-items: center;
      gap: 16px;
    }

    h1 { font-size: 1.1rem; font-weight: 600; }

    #connection-status {
      font-size: 0.8rem;
      padding: 3px 10px;
      border-radius: 999px;
      background: #fee2e2;
      color: #991b1b;
    }

    #connection-status.connected {
      background: #dcfce7;
      color: #166534;
    }

    #awareness-bar {
      margin-left: auto;
      display: flex;
      gap: 8px;
      align-items: center;
      font-size: 0.85rem;
      color: #666;
    }

    .user-pill {
      width: 28px;
      height: 28px;
      border-radius: 50%;
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 0.75rem;
      font-weight: 600;
      color: white;
    }

    main {
      max-width: 860px;
      margin: 32px auto;
      padding: 0 16px;
    }

    #editor-container {
      background: white;
      border-radius: 8px;
      box-shadow: 0 1px 3px rgba(0,0,0,0.1);
      overflow: hidden;
    }

    #editor {
      height: 600px;
      font-size: 1rem;
      line-height: 1.6;
    }

    /* Remote cursor styles — injected dynamically */
    .remote-cursor {
      position: absolute;
      width: 2px;
      pointer-events: none;
    }

    .remote-cursor-label {
      position: absolute;
      top: -18px;
      left: 0;
      font-size: 11px;
      padding: 1px 6px;
      border-radius: 3px;
      white-space: nowrap;
      color: white;
      font-weight: 600;
    }
  </style>
</head>
<body>
  <header>
    <h1>Collaborative Editor</h1>
    <span id="connection-status">Connecting...</span>
    <div id="awareness-bar">
      <span id="user-count">1 user</span>
      <div id="user-avatars"></div>
    </div>
  </header>

  <main>
    <div id="editor-container">
      <div id="editor"></div>
    </div>
  </main>

  <!-- Quill -->
  <script src="https://cdn.quilljs.com/1.3.7/quill.js"></script>

  <!-- Yjs and providers via CDN (ESM) -->
  <script type="module" src="client.js"></script>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

The Client Logic

Create public/client.js. This is where Yjs integrates with Quill:

import * as Y from 'https://cdn.jsdelivr.net/npm/yjs@13/dist/yjs.mjs';
import { WebsocketProvider } from 'https://cdn.jsdelivr.net/npm/y-websocket@1/dist/y-websocket.mjs';
import { QuillBinding } from 'https://cdn.jsdelivr.net/npm/y-quill@0.1/dist/y-quill.mjs';

// ─── User identity ───────────────────────────────────────────────────────────

// In a real app, get this from your auth system
const USER_COLORS = [
  '#e74c3c', '#3498db', '#2ecc71', '#f39c12',
  '#9b59b6', '#1abc9c', '#e67e22', '#34495e'
];

const userName = `User ${Math.floor(Math.random() * 1000)}`;
const userColor = USER_COLORS[Math.floor(Math.random() * USER_COLORS.length)];

// ─── Yjs document ────────────────────────────────────────────────────────────

// A Y.Doc is the root shared document
const ydoc = new Y.Doc();

// Y.Text is a CRDT-aware string — perfect for rich text
// The string 'quill' is just a key name within the document
const ytext = ydoc.getText('quill');

// ─── WebSocket provider ──────────────────────────────────────────────────────

// Connect to our server, syncing the document named 'demo-doc'
// Change 'demo-doc' in the URL to create separate documents
const wsProvider = new WebsocketProvider(
  'ws://localhost:1234',
  'demo-doc',
  ydoc
);

// ─── Connection status UI ────────────────────────────────────────────────────

const statusEl = document.getElementById('connection-status');

wsProvider.on('status', ({ status }) => {
  statusEl.textContent = status === 'connected' ? 'Connected' : 'Reconnecting...';
  statusEl.className = status === 'connected' ? 'connected' : '';
});

// ─── Quill editor ────────────────────────────────────────────────────────────

const quill = new Quill('#editor', {
  modules: {
    toolbar: [
      [{ header: [1, 2, 3, false] }],
      ['bold', 'italic', 'underline', 'strike'],
      [{ list: 'ordered' }, { list: 'bullet' }],
      ['blockquote', 'code-block'],
      ['link'],
      ['clean']
    ],
    history: {
      // Yjs handles undo/redo, not Quill
      userOnly: true
    }
  },
  placeholder: 'Start typing — others can join at localhost:3000...',
  theme: 'snow',
});

// ─── Bind Yjs ↔ Quill ────────────────────────────────────────────────────────

// QuillBinding keeps Quill's content in sync with ytext
// It also handles remote cursor rendering
const binding = new QuillBinding(ytext, quill, wsProvider.awareness);

// ─── Awareness (presence) ────────────────────────────────────────────────────

// Awareness is a separate CRDT that tracks ephemeral state
// (cursor positions, user info) — it doesn't persist
wsProvider.awareness.setLocalStateField('user', {
  name: userName,
  color: userColor,
});

// Update the presence UI whenever awareness changes
function updatePresenceUI() {
  const states = wsProvider.awareness.getStates();
  const users = [];

  states.forEach((state, clientId) => {
    if (state.user) {
      users.push({
        clientId,
        ...state.user,
        isLocal: clientId === wsProvider.awareness.clientID
      });
    }
  });

  // Update count
  const countEl = document.getElementById('user-count');
  countEl.textContent = `${users.length} ${users.length === 1 ? 'user' : 'users'}`;

  // Update avatars
  const avatarsEl = document.getElementById('user-avatars');
  avatarsEl.innerHTML = users.map(u => `
    <div
      class="user-pill"
      style="background: ${u.color}"
      title="${u.name}${u.isLocal ? ' (you)' : ''}"
    >
      ${u.name.charAt(u.name.length - 1)}
    </div>
  `).join('');
}

wsProvider.awareness.on('change', updatePresenceUI);
updatePresenceUI(); // Initial render

// ─── Undo/Redo via Yjs ───────────────────────────────────────────────────────

// Y.UndoManager tracks only the local user's changes
// Remote changes are never undone accidentally
const undoManager = new Y.UndoManager(ytext);

document.addEventListener('keydown', (e) => {
  if ((e.ctrlKey || e.metaKey) && e.key === 'z') {
    e.preventDefault();
    if (e.shiftKey) {
      undoManager.redo();
    } else {
      undoManager.undo();
    }
  }
});

console.log(`Connected as "${userName}" (${userColor})`);
Enter fullscreen mode Exit fullscreen mode

Running It

Open two terminal windows:

# Terminal 1: Start the WebSocket server
npm run server

# Terminal 2: Serve the client
npm run client
Enter fullscreen mode Exit fullscreen mode

Open http://localhost:3000 in two separate browser windows (or tabs). Start typing in one — you'll see the text appear in the other in real time. Notice that if you type in both simultaneously, neither editor loses content.


How the Sync Actually Works

When you type a character, here's what happens under the hood:

  1. Quill fires a text-change event with a Quill Delta (e.g., { ops: [{ retain: 5 }, { insert: 'x' }] })

  2. QuillBinding translates this into a Yjs operation and applies it to ytext. The Yjs operation is richer than a Quill Delta — it includes a unique client ID, a logical clock timestamp, and the preceding character's ID as an anchor.

  3. Yjs computes a compact binary encoding of the operation (typically 20-50 bytes per keystroke using lib0's encoding).

  4. y-websocket sends this binary update to the server, which immediately broadcasts it to all other connected clients.

  5. Each receiving client applies the update to its own Y.Doc. YATA's merge algorithm resolves any conflicts: if two insertions target the same position, they're ordered deterministically by client ID, so every peer reaches the same state.

  6. QuillBinding observes the ytext change and updates the Quill editor, adjusting all cursors accordingly.

The entire round-trip from keystroke to other-user's-screen is typically 10-50ms on a local network — imperceptibly fast.


Offline Support: Where CRDTs Really Shine

One of the most underappreciated benefits of CRDTs over OT is offline editing. Because every operation is self-describing and merge is commutative, a user can edit for an hour without connectivity and sync cleanly when they reconnect.

Let's add IndexedDB persistence so the document survives page refreshes:

npm install y-indexeddb
Enter fullscreen mode Exit fullscreen mode

Update the top of client.js:

import { IndexeddbPersistence } from 'https://cdn.jsdelivr.net/npm/y-indexeddb@9/dist/y-indexeddb.mjs';

// Persist the Yjs document to IndexedDB
// This loads the last known state instantly, before the WebSocket connects
const indexeddbProvider = new IndexeddbPersistence('demo-doc', ydoc);

indexeddbProvider.whenSynced.then(() => {
  console.log('Loaded from IndexedDB');
});
Enter fullscreen mode Exit fullscreen mode

Now when the page loads, the editor immediately shows the last known content from IndexedDB while the WebSocket connection is being established. When the WebSocket syncs, any remote changes are merged in. When offline, edits are saved locally and automatically pushed when connectivity resumes.

This is the pattern behind apps like Notion's offline mode, Linear's instant loading, and Figma's conflict-free multiplayer.


Production Considerations

The server we built is fine for demos but needs hardening for production:

Persistent Storage

The in-memory store loses all documents on server restart. Replace it with a database:

import { LeveldbPersistence } from 'y-leveldb';

// Store documents in LevelDB
const ldb = new LeveldbPersistence('./documents');

wss.on('connection', (ws, req) => {
  const docName = req.url?.slice(1) || 'default';

  setupWSConnection(ws, req, {
    docName,
    gc: true,
    // Persist: load from LevelDB, save updates back
    persistence: ldb,
  });
});
Enter fullscreen mode Exit fullscreen mode

For PostgreSQL, use y-postgresql. For Redis, use y-redis, which also enables horizontal scaling.

Authentication

The current server accepts any WebSocket connection. Add token validation:

wss.on('connection', (ws, req) => {
  const url = new URL(req.url, 'http://localhost');
  const token = url.searchParams.get('token');

  if (!isValidToken(token)) {
    ws.close(4001, 'Unauthorized');
    return;
  }

  setupWSConnection(ws, req, { docName: url.pathname.slice(1) });
});
Enter fullscreen mode Exit fullscreen mode

On the client:

const wsProvider = new WebsocketProvider(
  'wss://your-server.com',
  'document-id',
  ydoc,
  { params: { token: getUserToken() } }
);
Enter fullscreen mode Exit fullscreen mode

Scaling with Hocuspocus

For production-grade collaborative features without reinventing the wheel, consider Hocuspocus — a Yjs server framework from the TipTap team that includes authentication hooks, document lifecycle events, and database adapters:

import { Server } from '@hocuspocus/server';
import { Database } from '@hocuspocus/extension-database';
import { Logger } from '@hocuspocus/extension-logger';

const server = Server.configure({
  port: 1234,
  extensions: [
    new Logger(),
    new Database({
      fetch: async ({ documentName }) => {
        return await db.getDocument(documentName);
      },
      store: async ({ documentName, state }) => {
        await db.saveDocument(documentName, state);
      },
    }),
  ],

  async onAuthenticate({ token }) {
    const user = await verifyToken(token);
    if (!user) throw new Error('Unauthorized');
    return { user };
  },
});

server.listen();
Enter fullscreen mode Exit fullscreen mode

Hocuspocus is production-tested at TipTap, which powers the editor at thousands of SaaS companies.


Beyond Text: Other CRDT Data Types

Yjs isn't limited to text. It provides several shared types:

Type Use Case Example
Y.Text Rich text, code editors Document bodies
Y.Map Key-value stores Document metadata, user settings
Y.Array Ordered lists Kanban cards, todo items
Y.XmlFragment DOM trees Block-based editors

A collaborative whiteboard might use:

const ydoc = new Y.Doc();

// Track shape positions
const shapes = ydoc.getMap('shapes');

// Add a rectangle
shapes.set('rect-1', {
  type: 'rectangle',
  x: 100, y: 200,
  width: 300, height: 150,
  fill: '#3498db'
});

// Any connected peer sees this immediately
shapes.observe(event => {
  event.changes.keys.forEach((change, key) => {
    if (change.action === 'add' || change.action === 'update') {
      renderShape(key, shapes.get(key));
    }
  });
});
Enter fullscreen mode Exit fullscreen mode

This same CRDT pattern is exactly how Figma's multiplayer works — positions are Y.Maps, layers are Y.Arrays, and the entire canvas state is a single Y.Doc.


Performance and Bundle Size

Yjs is impressively lean:

  • yjs: ~13KB gzipped
  • y-websocket (client): ~7KB gzipped
  • y-quill: ~3KB gzipped

Total addition to your bundle: ~23KB gzipped. For comparison, Quill itself is ~43KB gzipped.

At scale, Yjs documents are efficient too. A 50,000-word collaborative document with a year of edit history typically serializes to 2-5MB, and the incremental updates per keystroke are 20-80 bytes.

One important consideration: Yjs documents grow over time as deleted content is retained as tombstones (necessary for CRDT correctness). For long-lived documents, periodic snapshot compaction is advisable:

// Compact the document state (removes tombstones)
// Do this server-side periodically, not on every save
const compactedState = Y.encodeStateAsUpdate(ydoc);
await db.saveCompactedDocument(docName, compactedState);
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

After building this, here's what to carry forward:

CRDTs are the right abstraction for collaboration. Operational transforms work, but they require a central server to serialize operations. CRDTs make every peer equal — there's no "master" copy, making them naturally suited for offline-first and peer-to-peer architectures.

Yjs handles the hard parts. The YATA algorithm, binary encoding, WebSocket sync protocol, awareness state — you don't need to implement any of this. Focus on your product.

Offline-first is nearly free. Adding y-indexeddb to a Yjs app costs 5 lines of code and gives you offline editing, instant load times, and automatic conflict resolution. There's no reason not to.

The ecosystem is production-ready. Yjs powers editors at Atlassian, Loom, and countless startups. TipTap's Hocuspocus gives you a battle-tested server layer. You're not experimenting — you're using proven infrastructure.

Think in documents, not operations. The mental shift from "send this diff to the server" to "here's a shared CRDT document all peers replicate" unlocks a whole category of features: branching/merging like Git, time-travel history, offline conflict resolution, and peer-to-peer sync.

The full project code is available on GitHub. Open two browser windows, start typing, and witness the quiet elegance of a well-designed distributed system.


Wilson Xu is a full-stack developer with 8+ years building production web applications. He specializes in real-time systems, developer tooling, and TypeScript. He writes about the web platform at wilsonxu.dev.

Top comments (0)