Skip to content

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.

5 min read
Architecture diagram of a collaborative editor showing multiple clients syncing document state through CRDT merge operations

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.

tstypescript
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.

tstypescript
// ❌ 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);
});
tstypescript
// ✅ 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.

tstypescript
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.

tstypescript
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.

tstypescript
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.

Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX