frontend / frontend system design / 05_collaborative_editor.md

Design: Collaborative Editor (CRDT / OT)

6 min read source

Design: Collaborative Editor (CRDT / OT)

TL;DR

A rich-text or structured editor where multiple users edit simultaneously, each seeing the others’ cursors and changes in near-real-time, with no merge conflicts. The senior topics: why naive last-write-wins is wrong, OT vs CRDT trade-offs, transport (WS) and resync, awareness (cursors/selections), persistence and history, and offline editing. Most teams don’t roll their own — they use Yjs, Automerge, or ShareDB. The interview answer leads with that, then explains why.

Requirements to clarify

  • What’s being edited. Plain text? Rich text (formatting)? Structured doc (Google Docs)? Spreadsheet (multi-cell, formulas)? Whiteboard (shapes, positions)? CRDT choice depends heavily on the data model.
  • Concurrency scale. 2 simultaneous editors or 200? Different solutions.
  • Offline edits. Must merge cleanly when reconnected? CRDTs shine; OT needs server mediation.
  • History / time travel. Granular undo per user? Full history? Branching?
  • Latency target. <100ms keystroke echo? <500ms? Drives whether changes round-trip the server.
  • End-to-end encryption? Conflict resolution gets much harder.

Why last-write-wins is wrong

Two users edit position 5 of the doc at the same time. Naive approach: each sends { pos: 5, insert: "X" }. Server applies them in arrival order. The second insert is at position 5 of a now-different document — it lands at the wrong place. Multiply by hundreds of operations per minute and the doc is garbled within seconds.

The two real solutions:

  • OT (Operational Transformation) — operations are transformed against concurrent operations so they apply correctly in the new context. Server is central authority; complex correctness proofs; what Google Docs uses.
  • CRDT (Conflict-free Replicated Data Type) — operations are commutative and idempotent by construction; any order of application yields the same result. Decentralized possible; what Yjs and Automerge implement; modern default.

CRDT vs OT — interview-level comparison

OT CRDT
Conflict model transform op against op merge state, commutative
Server required yes (central authority) optional (peer-to-peer possible)
Offline merge hard (server replay) natural
Memory overhead low higher (per-character metadata)
Implementation complexity high (correctness proofs) high (impl) but libraries hide it
Real systems Google Docs, ShareDB Yjs (Notion, Linear, etc.), Automerge (Local-first)
Newer tooling mature mature and active

Modern interview answer: “I’d use Yjs unless we already have OT infrastructure.” Yjs is the productionized CRDT for collaborative apps; it integrates with ProseMirror/Slate/Quill for rich text and provides awareness (cursors/selections) out of the box.

The library stack (Yjs example)

[ProseMirror or Slate or Quill]  ← editor framework

[y-prosemirror binding]          ← syncs editor state ↔ Yjs doc

[Y.Doc]                          ← the CRDT data structure

[y-websocket provider]           ← syncs the Y.Doc with the server (or peers)

[WebSocket server (y-websocket-server, or Hocuspocus)]
  • Y.Doc holds the CRDT state.
  • Editor binding translates DOM/editor events ↔ Y.Doc updates.
  • Provider ships updates over the wire and merges incoming.

The whole “merge conflicts go away” magic is in the Y.Doc layer. You never write merge code yourself.

Client architecture

import * as Y from "yjs";
import { WebsocketProvider } from "y-websocket";
import { yCollab } from "y-prosemirror";

const ydoc = new Y.Doc();
const provider = new WebsocketProvider("wss://server/yjs", `doc-${docId}`, ydoc);
const yXmlFragment = ydoc.getXmlFragment("prosemirror");
const awareness = provider.awareness;        // user cursors, selections, online users

// Bind to ProseMirror
const view = new EditorView(domNode, {
  state: EditorState.create({
    schema,
    plugins: [yCollab(yXmlFragment, awareness)],
  }),
});

// Set local user info
awareness.setLocalStateField("user", { name: "Ada", color: "#ff5500" });

awareness is Yjs’s mechanism for ephemeral presence (cursor position, selection, who’s currently in the doc). Awareness is not part of the CRDT — it’s lossy state, broadcast to other peers, with no persistence.

Persistence and history

  • Server persistence — the server snapshots the Y.Doc periodically (e.g., every 30s of inactivity) and persists the binary state. On open, the latest snapshot is loaded; subsequent updates ship live.
  • History — Yjs supports an undo manager (Y.UndoManager) scoped per origin (so each user can undo their own changes only).
  • Snapshots / time travel — Yjs stores enough metadata to reconstruct any prior state by replaying updates; Google Docs–style version history is achievable but you have to design it (UI to browse, restore semantics).

Awareness (cursors and selections)

awareness.on("change", (changes) => {
  const states = awareness.getStates();   // Map<clientId, {user, cursor, selection}>
  setRemoteCursors([...states.entries()].filter(([id]) => id !== awareness.clientID));
});

Render remote cursors as overlays anchored to the editor’s character positions. The selection (range) becomes a tinted background span. Color per user via awareness.setLocalStateField("user", { color }).

The challenge is cursor position stability under remote edits — if a remote user inserts text before your cursor, your cursor index must shift accordingly. Yjs handles this via relative positions (anchored to text segments, not absolute indices), and the binding maps them back to editor positions.

Offline editing

Pure CRDT property: edit offline, reconnect, merge cleanly. With Yjs:

  • IndexedDB persistence (y-indexeddb provider) keeps the Y.Doc local across reloads.
  • On reconnect, y-websocket syncs the local Y.Doc with the server — both sides exchange missing updates and converge.
  • The user sees their offline edits as soon as they came in; remote edits arrive once online.

Caveats:

  • Permission changes while offline — you edited the doc, then someone revoked your access. The server rejects your sync; client must surface this.
  • Schema migrations — changing the editor schema means existing CRDT state may need migration; design schema-stable.
  • Long offline periods — the CRDT state can grow with metadata; periodic GC / snapshot to keep size bounded.

Failure modes

  • WS drop mid-edit — local edits buffered in the Y.Doc; on reconnect, they sync. UI shows “offline / changes saved locally.”
  • Server snapshot lost — clients still have their local state; the first to reconnect re-seeds the server. (Hocuspocus handles this with leader election among connected clients.)
  • Two clients with diverging long offline edits — CRDT merges them; but the merged result may not be what either user expected. There’s no merge conflict UI; concurrent edits on the same text are interleaved by character. For structured (paragraph-level) data, splits/intentions are preserved.
  • Permission lost mid-session — server kicks the connection with a code; client surfaces “you no longer have access.”
  • Schema drift — server rolled out a schema that an old tab can’t render; force-reload that tab.

When to NOT use CRDT/OT

If the doc has a single editor at a time (one person, no concurrent edits), use simple last-write-wins with a version counter. Locking (“Alice is editing”) is also valid for forms / records where simultaneous edit is rare and reasonable to block.

If you only need comments or annotations on a static doc, you don’t need CRDT — see 06_document_comments.md.

Telemetry

  • Sync latency (edit → other clients render it).
  • Update size (large pastes cause big updates).
  • Reconnect frequency.
  • Document size (CRDT metadata growth).
  • Conflict count (Yjs surfaces structural merges) — usually low, but spikes signal client bugs.

What a senior is expected to say

  • “Naive concurrent editing requires either OT or CRDT — last-write-wins corrupts. I’d reach for Yjs (CRDT) unless an existing system mandates OT (e.g., ShareDB).”
  • “Yjs handles the merge math; my job is the editor binding, the provider (transport), persistence (IndexedDB + server snapshots), and awareness (cursors/selections).”
  • “Awareness is ephemeral; it’s broadcast separately from CRDT updates and isn’t persisted. Cursors anchor to relative positions so they survive remote edits.”
  • “Offline editing comes for free with CRDT; merge happens on reconnect. Caveat: there’s no conflict UI — concurrent edits interleave, which may surprise users on the same text.”
  • “Server is mostly a relay + snapshot store with Yjs; it doesn’t compute conflict resolution. The state lives in the doc.”

Cross-references

Further reading