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:
- A Node.js WebSocket server that relays Yjs document updates between clients
- 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
Create this directory structure:
collab-editor/
├── server.js
├── public/
│ ├── index.html
│ └── client.js
└── package.json
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}`);
});
Add a start script to package.json:
{
"type": "module",
"scripts": {
"server": "node server.js",
"client": "http-server public -p 3000"
}
}
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>
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})`);
Running It
Open two terminal windows:
# Terminal 1: Start the WebSocket server
npm run server
# Terminal 2: Serve the client
npm run client
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:
Quill fires a
text-changeevent with a Quill Delta (e.g.,{ ops: [{ retain: 5 }, { insert: 'x' }] })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.Yjs computes a compact binary encoding of the operation (typically 20-50 bytes per keystroke using lib0's encoding).
y-websocket sends this binary update to the server, which immediately broadcasts it to all other connected clients.
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.
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
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');
});
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,
});
});
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) });
});
On the client:
const wsProvider = new WebsocketProvider(
'wss://your-server.com',
'document-id',
ydoc,
{ params: { token: getUserToken() } }
);
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();
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));
}
});
});
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);
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)