Building Real-Time Collaborative Features with CRDTs and Yjs
By Wilson Xu
Introduction: The Problem Every Collaborative App Faces
Imagine two people editing the same document at the same time. Alice deletes the word "quickly" from a sentence. Bob, working on the same sentence from his laptop in Tokyo, inserts the word "carefully" right where "quickly" used to be. When both changes reach the server, what should the final document look like?
This is the fundamental challenge of real-time collaboration — and it has been the subject of serious distributed systems research for decades. If you have ever used Google Docs, Notion, Figma, or Linear, you have experienced the solution in action: no matter what you do, no matter how bad the network conditions are, the document always converges to a consistent state. Nothing gets permanently lost, no one sees a broken version.
For most of the last twenty years, the dominant solution was Operational Transformation (OT). OT is the algorithm behind Google Docs, and it works by transforming each operation against every concurrent operation before applying it. It works, but implementing OT correctly is notoriously difficult. There is a reason Google keeps its OT implementation closed-source: the edge cases are brutal, and the algorithm requires a central server to order operations consistently.
Enter CRDTs — Conflict-free Replicated Data Types. CRDTs take a fundamentally different approach. Instead of transforming operations, they design data structures that are mathematically guaranteed to converge to the same state regardless of the order in which updates are applied. No central coordinator required. No transformation logic to get wrong. Just pure, provable convergence.
In 2026, CRDTs are no longer an academic curiosity. They power Figma's multiplayer, they underpin local-first applications, and they are the foundation of a new generation of offline-capable collaborative tools. If you are building any kind of collaborative feature — shared documents, multiplayer state, presence systems — understanding CRDTs and their most popular JavaScript implementation, Yjs, is essential.
This article will take you from the theory to a fully working collaborative text editor with live cursors, persistence, and production considerations.
Understanding CRDTs: The Math That Makes Collaboration Easy
Before we write any code, it is worth spending a few minutes on what makes CRDTs special. There are two main families of CRDTs: state-based (CvRDTs) and operation-based (CmRDTs). We will focus on state-based ones because Yjs uses this model.
A state-based CRDT has three properties:
-
Associativity:
merge(merge(a, b), c) = merge(a, merge(b, c)) -
Commutativity:
merge(a, b) = merge(b, a) -
Idempotency:
merge(a, a) = a
Together these properties mean: it does not matter what order you merge updates, or even if you merge the same update twice. The result is always the same. This is the mathematical guarantee that makes CRDTs safe to use in distributed systems without coordination.
G-Counter: The Simplest CRDT
A G-Counter (grow-only counter) is the canonical introductory CRDT. Imagine a counter that can only increment, shared across three nodes:
// Each node maintains counts for ALL nodes
const nodeA = { a: 5, b: 3, c: 1 }; // Node A's view
const nodeB = { a: 4, b: 7, c: 1 }; // Node B's view (hasn't seen A's latest)
// Merge: take the max of each node's count
function mergeGCounter(a, b) {
const result = {};
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
for (const key of keys) {
result[key] = Math.max(a[key] || 0, b[key] || 0);
}
return result;
}
const merged = mergeGCounter(nodeA, nodeB);
// { a: 5, b: 7, c: 1 }
// Total value: sum of all entries = 13
console.log(Object.values(merged).reduce((sum, v) => sum + v, 0)); // 13
Node A incremented to 5 and Node B incremented to 7, and the merge correctly reflects both without double-counting. The total is simply the sum of the maximums.
LWW-Register: Last-Write-Wins
An LWW-Register (Last-Write-Wins Register) holds a single value, with conflicts resolved by keeping the most recent write based on a timestamp:
class LWWRegister {
constructor(id) {
this.id = id;
this.value = null;
this.timestamp = 0;
}
set(value, timestamp = Date.now()) {
if (timestamp > this.timestamp) {
this.value = value;
this.timestamp = timestamp;
}
}
merge(other) {
if (other.timestamp > this.timestamp) {
this.value = other.value;
this.timestamp = other.timestamp;
}
// If timestamps are equal, we could use node ID as tiebreaker
// This is a design choice — different implementations handle it differently
}
}
const regA = new LWWRegister('nodeA');
const regB = new LWWRegister('nodeB');
regA.set('hello', 1000);
regB.set('world', 2000);
regA.merge(regB);
console.log(regA.value); // 'world' — higher timestamp wins
LWW-Registers are simple and fast but they can silently discard data (Alice's edit at timestamp 999 loses to Bob's at timestamp 1000). They are useful for things like "last selected color" in a design tool, but bad for text editing.
OR-Set: Add Always Wins Over Remove
An Observed-Remove Set (OR-Set) solves a subtle problem: what if Alice adds an element at the same time Bob removes it? The OR-Set tags each add with a unique ID, so a remove can only remove a specific tagged instance:
class ORSet {
constructor() {
this.entries = new Map(); // element -> Set of add-tags
this.tombstones = new Set(); // removed add-tags
}
add(element) {
const tag = crypto.randomUUID();
if (!this.entries.has(element)) {
this.entries.set(element, new Set());
}
this.entries.get(element).add(tag);
return tag;
}
remove(element) {
if (this.entries.has(element)) {
for (const tag of this.entries.get(element)) {
this.tombstones.add(tag);
}
this.entries.delete(element);
}
}
has(element) {
const tags = this.entries.get(element);
return tags && tags.size > 0;
}
merge(other) {
// Add all tombstones from other
for (const tag of other.tombstones) {
this.tombstones.add(tag);
}
// Add elements from other, filtering tombstoned tags
for (const [element, tags] of other.entries) {
for (const tag of tags) {
if (!this.tombstones.has(tag)) {
if (!this.entries.has(element)) {
this.entries.set(element, new Set());
}
this.entries.get(element).add(tag);
}
}
}
}
}
The OR-Set is the conceptual ancestor of how Yjs handles collaborative text: every character insertion is tagged uniquely, and concurrent edits never destroy each other.
Setting Up Yjs
Yjs is a production-ready CRDT library for JavaScript that implements a variant of the LSEQ/RGA algorithm for sequences (text, arrays) and also provides Maps and XML-based types. It is fast, battle-tested, and powers tools used by millions of people daily.
Install the core package and a WebSocket provider:
npm install yjs y-websocket
# For persistence
npm install y-indexeddb
# For a rich text editor integration
npm install @tiptap/core @tiptap/pm @tiptap/starter-kit @tiptap/extension-collaboration @tiptap/extension-collaboration-cursor
The central object in Yjs is Y.Doc. Think of it as a shared, conflict-free database that automatically syncs across all connected clients:
import * as Y from 'yjs';
// Create a shared document
const ydoc = new Y.Doc();
// Get a shared text type
const ytext = ydoc.getText('content');
// Get a shared map type (great for metadata, settings)
const ymeta = ydoc.getMap('metadata');
// Get a shared array type
const yarray = ydoc.getArray('comments');
// Listen for any changes
ydoc.on('update', (update, origin) => {
console.log('Document updated from:', origin);
// `update` is a Uint8Array — a binary diff you can send to other peers
});
// Apply a remote update
Y.applyUpdate(ydoc, remoteUpdateBytes);
// Get the full document state as binary (for storing or sending)
const state = Y.encodeStateAsUpdate(ydoc);
The key insight: Y.encodeStateAsUpdate produces a compact binary representation of your document state. You can send this over a WebSocket, store it in a database, or keep it in IndexedDB. When two clients exchange their states, Yjs automatically merges them without conflicts.
Building a Collaborative Text Editor
Let us build a real collaborative editor step by step. We will use Tiptap (a headless rich text editor built on ProseMirror) with Yjs integration.
Server: The WebSocket Sync Server
First, set up the WebSocket server. The y-websocket package provides a ready-made server that handles the sync protocol:
// server.js
import { WebSocketServer } from 'ws';
import { setupWSConnection } from 'y-websocket/bin/utils.js';
import http from 'http';
const server = http.createServer((req, res) => {
res.writeHead(200);
res.end('Collaborative Editor Server');
});
const wss = new WebSocketServer({ server });
wss.on('connection', (ws, req) => {
// The room name comes from the URL path, e.g., /my-document-id
const roomName = req.url?.slice(1) || 'default-room';
setupWSConnection(ws, req, {
docName: roomName,
gc: true, // Enable garbage collection for deleted content
});
console.log(`Client connected to room: ${roomName}`);
});
server.listen(1234, () => {
console.log('WebSocket server running on ws://localhost:1234');
});
Run this with node server.js. In production you would deploy this to a service like Fly.io or Railway — it is stateless between restarts unless you add persistence (covered later).
Client: The React Collaborative Editor
Now the front-end. This is the complete component:
// CollaborativeEditor.jsx
import { useEffect, useRef, useMemo } from 'react';
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Collaboration from '@tiptap/extension-collaboration';
import CollaborationCursor from '@tiptap/extension-collaboration-cursor';
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import { IndexeddbPersistence } from 'y-indexeddb';
// Generate a consistent color for each user
function getUserColor(name) {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
const h = hash % 360;
return `hsl(${Math.abs(h)}, 70%, 45%)`;
}
export function CollaborativeEditor({ documentId, currentUser }) {
const ydocRef = useRef(null);
const providerRef = useRef(null);
// Initialize Yjs document and providers only once
const { ydoc, provider } = useMemo(() => {
const ydoc = new Y.Doc();
// WebSocket provider for real-time sync
const provider = new WebsocketProvider(
'ws://localhost:1234',
documentId,
ydoc,
{
connect: true,
// Reconnect with exponential backoff
maxBackoffTime: 2500,
}
);
// IndexedDB provider for offline persistence
const persistence = new IndexeddbPersistence(documentId, ydoc);
persistence.on('synced', () => {
console.log('Content loaded from IndexedDB');
});
return { ydoc, provider };
}, [documentId]);
const editor = useEditor({
extensions: [
StarterKit.configure({
// Disable history — Yjs handles undo/redo
history: false,
}),
Collaboration.configure({
document: ydoc,
field: 'content', // Which Y.XmlFragment to use
}),
CollaborationCursor.configure({
provider,
user: {
name: currentUser.name,
color: getUserColor(currentUser.name),
},
// Custom cursor render (optional)
render(user) {
const cursor = document.createElement('span');
cursor.classList.add('collab-cursor');
cursor.style.borderLeft = `2px solid ${user.color}`;
const label = document.createElement('div');
label.classList.add('collab-cursor-label');
label.style.backgroundColor = user.color;
label.textContent = user.name;
cursor.appendChild(label);
return cursor;
},
}),
],
content: '',
autofocus: true,
});
// Cleanup on unmount
useEffect(() => {
return () => {
provider.destroy();
ydoc.destroy();
};
}, [provider, ydoc]);
if (!editor) return <div>Loading editor...</div>;
return (
<div className="editor-container">
<div className="toolbar">
<button onClick={() => editor.chain().focus().toggleBold().run()}
className={editor.isActive('bold') ? 'active' : ''}>
Bold
</button>
<button onClick={() => editor.chain().focus().toggleItalic().run()}
className={editor.isActive('italic') ? 'active' : ''}>
Italic
</button>
<button onClick={() => editor.commands.undo()}>Undo</button>
<button onClick={() => editor.commands.redo()}>Redo</button>
</div>
<EditorContent editor={editor} className="editor-content" />
</div>
);
}
This component gives you:
- Real-time collaborative editing with automatic conflict resolution
- Offline support via IndexedDB (edits made offline sync when reconnected)
- Shared undo/redo history (Ctrl+Z undoes your own changes, not others')
- Cursor presence (covered next)
Adding Presence: Who Is Online, Where Are Their Cursors
Presence — knowing who else is in the document and where their cursor is — transforms a technical sync system into a human collaborative experience. Yjs provides this through the Awareness protocol, which is separate from document state.
The key difference: document state is permanent and synced to all clients including future ones. Awareness state is ephemeral — it describes the current session (cursor position, user name, online status) and is lost when someone disconnects.
// awareness-demo.js — shows the Awareness API directly
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import { awarenessStatesToArray } from 'y-protocols/awareness.js';
const ydoc = new Y.Doc();
const provider = new WebsocketProvider('ws://localhost:1234', 'my-room', ydoc);
const awareness = provider.awareness;
// Set our own state
awareness.setLocalStateField('user', {
name: 'Alice',
color: '#ff6b6b',
});
awareness.setLocalStateField('cursor', {
anchor: { path: [0, 0], offset: 5 },
focus: { path: [0, 0], offset: 5 },
});
// Listen for changes from ALL clients (including ourselves)
awareness.on('change', ({ added, updated, removed }) => {
const states = awareness.getStates();
console.log('Currently online users:');
states.forEach((state, clientId) => {
if (state.user) {
console.log(` ${state.user.name} (client ${clientId})`);
if (state.cursor) {
console.log(` Cursor at offset: ${state.cursor.anchor.offset}`);
}
}
});
if (removed.length > 0) {
console.log(`${removed.length} user(s) disconnected`);
}
});
// Build a presence indicator UI
function renderOnlineUsers() {
const states = awareness.getStates();
const users = [];
states.forEach((state, clientId) => {
if (state.user && clientId !== awareness.clientID) {
users.push({
...state.user,
clientId,
isYou: clientId === awareness.clientID,
});
}
});
return users;
}
Building an Online Users Panel
Here is a React hook that wraps the Awareness API:
// useCollabPresence.js
import { useState, useEffect } from 'react';
export function useCollabPresence(provider) {
const [onlineUsers, setOnlineUsers] = useState([]);
useEffect(() => {
if (!provider) return;
const awareness = provider.awareness;
function updateUsers() {
const states = awareness.getStates();
const users = [];
states.forEach((state, clientId) => {
if (state.user) {
users.push({
...state.user,
clientId,
isCurrentUser: clientId === awareness.clientID,
});
}
});
setOnlineUsers(users);
}
awareness.on('change', updateUsers);
updateUsers(); // Initial state
return () => awareness.off('change', updateUsers);
}, [provider]);
return onlineUsers;
}
// Usage in a component
function CollabPresenceBar({ provider }) {
const users = useCollabPresence(provider);
return (
<div className="presence-bar">
<span>{users.length} online</span>
{users.map(user => (
<div key={user.clientId}
className="user-avatar"
style={{ backgroundColor: user.color }}
title={user.isCurrentUser ? `${user.name} (you)` : user.name}>
{user.name[0].toUpperCase()}
</div>
))}
</div>
);
}
Persistence with Y-IndexedDB
The y-indexeddb package stores the Yjs document state in the browser's IndexedDB, enabling full offline support. When the user opens the document without internet, they see their last known state and can continue editing. When they reconnect, changes sync automatically using the CRDT merge:
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import { IndexeddbPersistence } from 'y-indexeddb';
async function initDocument(documentId) {
const ydoc = new Y.Doc();
// Local persistence — loads immediately from disk
const localPersistence = new IndexeddbPersistence(documentId, ydoc);
// Wait for local data to load before connecting to server
await localPersistence.whenSynced;
console.log('Loaded local data. Characters in document:',
ydoc.getText('content').length);
// Now connect to server — it will sync any missing remote changes
const wsProvider = new WebsocketProvider(
'wss://your-server.com',
documentId,
ydoc
);
wsProvider.on('status', ({ status }) => {
console.log('WebSocket status:', status); // 'connecting', 'connected', 'disconnected'
});
// Export document state for server-side storage
// (useful for backup or server-side rendering)
const exportState = () => Y.encodeStateAsUpdate(ydoc);
const importState = (bytes) => Y.applyUpdate(ydoc, bytes);
// Example: save to your backend every 30 seconds as a backup
const backupInterval = setInterval(async () => {
const stateBytes = exportState();
await fetch(`/api/documents/${documentId}/backup`, {
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
body: stateBytes,
});
}, 30_000);
return {
ydoc,
wsProvider,
localPersistence,
cleanup: () => {
clearInterval(backupInterval);
wsProvider.destroy();
localPersistence.destroy();
ydoc.destroy();
},
};
}
The beauty of this architecture: the WebSocket provider and IndexedDB provider both operate on the same Y.Doc. Yjs handles merging their updates transparently. If you make changes offline and come back online, Yjs sends only the diff (the changes the server has not seen), not the entire document.
Real-World Considerations
Performance and Document Size
Yjs documents grow over time because they retain the history of all insertions and deletions (tombstones). For a simple text file, this is rarely a problem — a 50,000 word document might be only a few hundred KB as a Yjs binary. But for heavily edited documents with thousands of operations over months, you will want to manage this.
The gc: true option in the WebSocket server setup enables garbage collection, which removes tombstones for content that has been deleted by all clients. Enable this in production. You can also use snapshots to create point-in-time copies of the document state that are independent of the full history.
Scaling the WebSocket Server
The default y-websocket server holds document state in memory. This means:
- It does not survive server restarts
- It cannot scale horizontally (multiple servers would have different in-memory state)
For production, you have two good options:
Option 1: Persistent WebSocket server using the y-leveldb or y-mongodb bindings to persist document state on the server:
npm install y-leveldb
# Then use the persistent server:
HOST=localhost PORT=1234 YPERSISTENCE=./yjs-docs npx y-websocket
Option 2: Hocuspocus — a production-ready Yjs backend that supports multiple database backends, authentication hooks, and horizontal scaling:
import { Server } from '@hocuspocus/server';
import { Database } from '@hocuspocus/extension-database';
const server = Server.configure({
port: 1234,
extensions: [
new Database({
fetch: async ({ documentName }) => {
// Load from your database
const doc = await db.documents.findOne({ id: documentName });
return doc?.yjsState ?? null;
},
store: async ({ documentName, state }) => {
// Save binary state to your database
await db.documents.upsert({ id: documentName, yjsState: state });
},
}),
],
async onAuthenticate(data) {
// Verify JWT, check document access
const { token, documentName } = data;
const user = await verifyToken(token);
if (!user.canAccess(documentName)) {
throw new Error('Not authorized');
}
},
});
server.listen();
Network Topology Choices
Yjs supports multiple synchronization topologies:
- Client-server (WebSocket): The most common choice. Simple, well-understood, easy to scale with Hocuspocus. Requires a server to be online for sync.
-
Peer-to-peer (WebRTC): Use
y-webrtcfor P2P sync viay-webrtc. Great for local-network collaboration or scenarios where you want no central server. Less reliable for cross-internet sync. - Hybrid: Combine both — use WebRTC for low-latency local sync and WebSocket as a fallback/relay.
Conflict Resolution at the Application Level
CRDTs guarantee convergence, but convergence is not always the same as semantic correctness. Consider: if two users simultaneously change a document title, both changes will be reflected in the Y.Map and the last write will win (since map keys use LWW semantics). This is usually fine for titles, but may not be for, say, a budget field where you want both increments to apply.
The solution: use the right CRDT type for each piece of data. Use Y.Text for prose content, Y.Map for key-value metadata (where LWW is acceptable), and Y.Array for ordered collections. For numeric counters that multiple users increment, you can layer a G-Counter on top of a Y.Map.
Testing Collaborative Features
Testing real-time collaboration is tricky but essential. Here is a pattern for unit-testing CRDT behavior:
// collaboration.test.js
import * as Y from 'yjs';
describe('Collaborative text editing', () => {
test('concurrent insertions converge correctly', () => {
// Simulate two clients
const docA = new Y.Doc();
const docB = new Y.Doc();
const textA = docA.getText('content');
const textB = docB.getText('content');
// Both start from same state
textA.insert(0, 'Hello World');
const initialState = Y.encodeStateAsUpdate(docA);
Y.applyUpdate(docB, initialState);
// Make concurrent edits (no sync between them)
docA.transact(() => {
textA.insert(5, ' Beautiful');
});
docB.transact(() => {
textB.insert(5, ' Wonderful');
});
// Sync both ways
Y.applyUpdate(docA, Y.encodeStateAsUpdate(docB));
Y.applyUpdate(docB, Y.encodeStateAsUpdate(docA));
// Both should have the same content
expect(textA.toString()).toBe(textB.toString());
// Both insertions should be present
expect(textA.toString()).toContain('Beautiful');
expect(textA.toString()).toContain('Wonderful');
});
});
This pattern — create two independent Y.Doc instances, make concurrent edits, sync them, assert convergence — is the core of collaborative feature testing. Run these tests in your CI pipeline and you will catch edge cases before your users do.
Conclusion
CRDTs represent a genuine paradigm shift in how we think about distributed state. Where OT required a central arbiter and complex transformation logic, CRDTs offer mathematical guarantees of convergence with no central coordination. The trade-off — storing more metadata, using more memory — is almost always worth it for the simplicity it brings to your architecture.
Yjs makes this practical for everyday JavaScript developers. In a few dozen lines of code, we built a collaborative editor with real-time sync, offline support, live cursors, and presence indicators. The same Yjs primitives — Y.Doc, Y.Text, Y.Map, Y.Array, and the Awareness protocol — can power any collaborative feature you can imagine: shared whiteboards, multiplayer spreadsheets, collaborative code editors, or real-time design tools.
The local-first movement, which CRDTs enable, is reshaping what users expect from software. Documents that work offline and sync seamlessly, applications that do not fail when the server is down, collaboration that does not require everyone to be simultaneously connected — these are no longer luxuries. They are becoming table stakes.
The tooling in 2026 is mature enough that there is no excuse for building collaborative features on fragile, conflict-prone architectures. Start with Yjs, layer on Hocuspocus for production persistence, and you will have a collaborative foundation that scales from a weekend project to millions of users.
Wilson Xu is a software engineer specializing in distributed systems and developer tooling. He writes about real-time systems, TypeScript, and the infrastructure behind modern web applications.
Top comments (0)