Building a Real-Time Collaborative Editor with CRDTs
Step-by-step implementation of a collaborative text editor with CRDTs: the Yjs library, WebSocket synchronization, the awareness protocol and offline-first.

Operational Transformation (OT)—Google Docs' approach—requires a central server to resolve conflicts. CRDTs flip this model entirely. Conflict-free Replicated Data Types are data structures that can be merged without coordination, meaning every client can edit independently and convergence is mathematically guaranteed. No central authority needed.
This makes CRDTs ideal for collaborative editors that need to work across unreliable networks, support offline editing, and scale without a single point of coordination.
Setting Up the CRDT Document
Yjs is the most battle-tested CRDT library for JavaScript. It provides shared data types that synchronize automatically across clients.
import * as Y from "yjs";
// Create a Yjs document — this is the CRDT container
const ydoc = new Y.Doc();
// Get a shared text type — edits to this are automatically
// conflict-free across all connected peers
const ytext = ydoc.getText("editor");
// Observe changes to the shared text
ytext.observe((event) => {
event.delta.forEach((op) => {
if (op.insert) {
console.log(`Inserted: "${op.insert}"`);
}
if (op.delete) {
console.log(`Deleted ${op.delete} characters`);
}
if (op.retain) {
console.log(`Retained ${op.retain} characters`);
}
});
});
// Edits from any connected client automatically merge
ytext.insert(0, "Hello, ");
ytext.insert(7, "world!");
console.log(ytext.toString()); // "Hello, world!"The key insight: there's no "conflict resolution" to implement. Yjs handles the merge semantics internally using a variant of the YATA algorithm. Two users typing at the same position will see both insertions appear in a deterministic order.
WebSocket Provider for Real-Time Sync
The CRDT document needs a transport layer to exchange updates between clients. Here's a WebSocket server that relays CRDT updates.
// ❌ Naive approach: sending full document state on every change
ws.on("message", (data) => {
const fullState = JSON.parse(data);
// Broadcasting entire document — wastes bandwidth,
// doesn't handle concurrent edits
broadcast(fullState);
});// ✅ CRDT-aware sync: exchange only incremental updates
import { WebSocketServer } from "ws";
import * as Y from "yjs";
import {
encodeStateAsUpdate,
applyUpdate,
encodeStateVector,
} from "yjs";
interface Room {
doc: Y.Doc;
clients: Set<WebSocket>;
}
const rooms: Map<string, Room> = new Map();
function getOrCreateRoom(roomId: string): Room {
let room = rooms.get(roomId);
if (!room) {
room = { doc: new Y.Doc(), clients: new Set() };
rooms.set(roomId, room);
}
return room;
}
const wss = new WebSocketServer({ port: 4000 });
wss.on("connection", (ws, req) => {
const roomId = new URL(
req.url ?? "/",
"http://localhost"
).searchParams.get("room");
if (!roomId) {
ws.close(1008, "Room ID required");
return;
}
const room = getOrCreateRoom(roomId);
room.clients.add(ws);
// Send current document state to new client
const stateVector = encodeStateVector(room.doc);
const update = encodeStateAsUpdate(room.doc);
ws.send(
JSON.stringify({ type: "sync-step-1", update: toBase64(update) })
);
ws.on("message", (data) => {
const msg = JSON.parse(data.toString());
if (msg.type === "update") {
const updateBytes = fromBase64(msg.update);
// Apply update to server document
applyUpdate(room.doc, updateBytes);
// Broadcast to all other clients in the room
for (const client of room.clients) {
if (client !== ws && client.readyState === 1) {
client.send(data.toString());
}
}
}
});
ws.on("close", () => {
room.clients.delete(ws);
if (room.clients.size === 0) {
// Persist document before cleanup
persistDocument(roomId, room.doc);
}
});
});
function toBase64(bytes: Uint8Array): string {
return Buffer.from(bytes).toString("base64");
}
function fromBase64(str: string): Uint8Array {
return new Uint8Array(Buffer.from(str, "base64"));
}
async function persistDocument(
roomId: string,
doc: Y.Doc
): Promise<void> {
const state = encodeStateAsUpdate(doc);
// Store in database for later retrieval
console.log(
`Persisting room ${roomId}: ${state.byteLength} bytes`
);
}The server only relays CRDT updates—it doesn't interpret or transform them. This means the server is simple and stateless regarding edit logic. All the merge intelligence lives in the CRDT library on each client.
Client-Side Integration with a Text Editor
Connecting Yjs to a real editor requires binding the CRDT document to the editor's internal model. Here's an integration with a basic contenteditable element.
import * as Y from "yjs";
class EditorBinding {
private ytext: Y.Text;
private element: HTMLElement;
private isLocalChange = false;
constructor(ydoc: Y.Doc, element: HTMLElement) {
this.ytext = ydoc.getText("editor");
this.element = element;
// Initialize editor with current CRDT state
this.element.textContent = this.ytext.toString();
// Listen for CRDT changes (from remote peers)
this.ytext.observe(this.onRemoteChange.bind(this));
// Listen for local edits
this.element.addEventListener(
"input",
this.onLocalChange.bind(this)
);
}
private onRemoteChange(event: Y.YTextEvent): void {
if (this.isLocalChange) return;
// Save cursor position
const selection = window.getSelection();
const cursorOffset = selection?.focusOffset ?? 0;
// Apply remote changes to the DOM
this.element.textContent = this.ytext.toString();
// Restore cursor position (simplified)
this.restoreCursor(cursorOffset);
}
private onLocalChange(): void {
this.isLocalChange = true;
const newText = this.element.textContent ?? "";
const currentText = this.ytext.toString();
// Compute diff and apply as CRDT operations
const { index, remove, insert } = this.computeDiff(
currentText,
newText
);
this.ytext.doc?.transact(() => {
if (remove > 0) this.ytext.delete(index, remove);
if (insert) this.ytext.insert(index, insert);
});
this.isLocalChange = false;
}
private computeDiff(
oldText: string,
newText: string
): { index: number; remove: number; insert: string } {
let start = 0;
while (
start < oldText.length &&
start < newText.length &&
oldText[start] === newText[start]
) {
start++;
}
let oldEnd = oldText.length;
let newEnd = newText.length;
while (
oldEnd > start &&
newEnd > start &&
oldText[oldEnd - 1] === newText[newEnd - 1]
) {
oldEnd--;
newEnd--;
}
return {
index: start,
remove: oldEnd - start,
insert: newText.slice(start, newEnd),
};
}
private restoreCursor(offset: number): void {
const range = document.createRange();
const textNode = this.element.firstChild;
if (textNode) {
const safeOffset = Math.min(
offset,
textNode.textContent?.length ?? 0
);
range.setStart(textNode, safeOffset);
range.collapse(true);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
}
}
}Awareness Protocol for Cursor Sharing
Showing other users' cursors and selections makes collaboration feel tangible. Yjs provides an awareness protocol for ephemeral state that doesn't need conflict resolution.
import { Awareness } from "y-protocols/awareness";
import * as Y from "yjs";
interface UserAwareness {
name: string;
color: string;
cursor: { index: number; length: number } | null;
}
class CursorManager {
private awareness: Awareness;
private cursorElements: Map<number, HTMLElement> = new Map();
constructor(ydoc: Y.Doc, user: { name: string; color: string }) {
this.awareness = new Awareness(ydoc);
// Set local user state
this.awareness.setLocalState({
name: user.name,
color: user.color,
cursor: null,
});
// Listen for remote awareness changes
this.awareness.on(
"change",
({ added, updated, removed }: {
added: number[];
updated: number[];
removed: number[];
}) => {
for (const id of added.concat(updated)) {
this.renderRemoteCursor(id);
}
for (const id of removed) {
this.removeRemoteCursor(id);
}
}
);
}
updateLocalCursor(index: number, length: number = 0): void {
this.awareness.setLocalStateField("cursor", {
index,
length,
});
}
private renderRemoteCursor(clientId: number): void {
if (clientId === this.awareness.clientID) return;
const state = this.awareness.getStates().get(clientId) as
| UserAwareness
| undefined;
if (!state?.cursor) return;
let el = this.cursorElements.get(clientId);
if (!el) {
el = document.createElement("span");
el.className = "remote-cursor";
document.querySelector(".editor")?.appendChild(el);
this.cursorElements.set(clientId, el);
}
el.style.borderLeftColor = state.color;
el.setAttribute("data-name", state.name);
// Position based on cursor index (simplified)
}
private removeRemoteCursor(clientId: number): void {
const el = this.cursorElements.get(clientId);
el?.remove();
this.cursorElements.delete(clientId);
}
}Offline Support and State Merging
CRDTs shine when clients go offline. Edits accumulate locally and merge seamlessly when connectivity returns.
class OfflineManager {
private dbName: string;
private ydoc: Y.Doc;
constructor(ydoc: Y.Doc, documentId: string) {
this.dbName = `crdt-${documentId}`;
this.ydoc = ydoc;
// Persist every update to IndexedDB
this.ydoc.on("update", (update: Uint8Array) => {
this.storeUpdate(update);
});
}
private async storeUpdate(update: Uint8Array): Promise<void> {
const db = await this.openDB();
const tx = db.transaction("updates", "readwrite");
const store = tx.objectStore("updates");
await store.add({
update: update,
timestamp: Date.now(),
});
}
async loadPersistedState(): Promise<void> {
const db = await this.openDB();
const tx = db.transaction("updates", "readonly");
const store = tx.objectStore("updates");
const allUpdates = await store.getAll();
// Apply all stored updates to the document
for (const record of allUpdates) {
Y.applyUpdate(this.ydoc, record.update);
}
}
private openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, 1);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains("updates")) {
db.createObjectStore("updates", {
autoIncrement: true,
});
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
}When the client reconnects, the CRDT sync protocol exchanges state vectors—compact summaries of what each peer has seen—and then sends only the missing updates. No full document transfer needed, no conflict resolution required. The math guarantees convergence regardless of edit ordering.
Key Takeaways
CRDTs eliminate conflict resolution entirely by using data structures that merge deterministically regardless of operation order. Yjs provides production-grade CRDT primitives—shared text, arrays, and maps—that handle the algorithmic complexity so you focus on the editor experience. The WebSocket server in a CRDT architecture is a simple relay that broadcasts updates without interpreting them, making it stateless and easy to scale. Bind the CRDT document to your editor by computing minimal diffs from local input events and applying them as CRDT operations. Use the awareness protocol for ephemeral state like cursor positions and user presence—these don't need CRDT guarantees since they're transient. Persist CRDT updates to IndexedDB for offline support, and rely on state vector exchange for efficient reconnection sync. The result is a collaborative editor that works offline, handles network partitions gracefully, and scales without a central coordination server.


