Frontend System Design: Collaborative Editor (Google Docs)

interviewOctober 26, 2026ยท 12 min read

"Design a collaborative editor like Google Docs" is arguably the most-asked senior frontend system design question, and the one where canned answers die fastest. The junior answer is "use contentEditable and send the text over a WebSocket." The senior answer knows a collaborative editor is an append-only operation log wearing a text UI: users type characters, but the system stores operations โ€” insert, delete, format โ€” and every client projects the same op log into the same document. Text is the projection. The log is the truth.

A collaborative editor is four systems stacked: a data-model problem (what is an operation, and what do you apply it to?), a sync problem (how do concurrent edits merge โ€” OT or CRDT?), a presence problem (how do remote cursors and selections move in real time without being persisted?), and an offline problem (how does the doc work on a plane and reconcile on reconnect?). Let's design all four.

Requirements โ€” the questions that split junior from senior

Before drawing anything, ask:

  • Rich text or plain text? A shared notepad (plain) and Google Docs (blocks, inline formatting, comments, images) are different designs. Rich text forces a block-based model, not a single string.
  • Scale of concurrency? Two people pair-editing, or fifty people in a meeting-notes doc? Concurrency degree changes the merge strategy and the fan-out design.
  • Latency budget? Presence must feel instant (under 100ms-ish); document sync can tolerate hundreds of milliseconds. Two different channels.
  • Offline? Must editing work with no connection and merge cleanly on reconnect? This single requirement eliminates every "send the whole text and hope" design.
  • Undo semantics? Undo in a multi-user doc is genuinely hard โ€” "undo my last change, not the doc's last change." Interviewers love watching you discover this.
  • History? Do users need version history and restore? That's a snapshot strategy question, not just a sync one.

I'll assume the full production version: rich text, 10โ€“50 concurrent users per doc, live cursors, offline editing with reconciliation, per-user undo, and version history.

The naive answer โ€” and exactly where it dies

The naive answer: render a contentEditable div, listen to input events, and broadcast the new HTML (or the new text) to everyone.

It fails in four named ways:

  1. The last-writer-wins wipeout. Alice and Bob type at the same time; each broadcast replaces the whole document. Alice's paragraph vanishes under Bob's version. Whole-document state sync cannot represent concurrency at all.
  2. contentEditable is a black box. The browser owns the DOM mutations. You get an input event after the fact, with no intent: a paste, a spellcheck autocorrect, a drag-drop, and a keystroke all look identical. You cannot build a deterministic op log from HTML diffs โ€” the same visible text can be arbitrarily different DOM (nested spans, split text nodes).
  3. The composition (IME) trap. Type Japanese or Chinese: the browser holds a composition that you cannot cancel, reject, or re-render without corrupting it. Any design that rewrites the DOM during composition breaks every CJK input method โ€” a billion users.
  4. Cursor chaos. Even if text merged correctly, there's no design for remote cursors, and locally the browser silently moves the caret when surrounding DOM changes โ€” type before a remote user's insertion and your caret teleports.

The tell: contentEditable is a rendering convenience, not a data model. Every serious editor (Google Docs, Figma, Notion, ProseMirror-based products) owns the document state and treats the DOM as a projection it controls.

The core decision: the document is an op log

The foundational move: model the document as a block-based structure mutated only by operations, and derive the rendered text from it.

// A block: the unit of structure (paragraph, list item, heading...)
interface Block {
  id: string;          // stable unique id โ€” never an array index
  type: "paragraph" | "heading" | "list-item";
  text: string;        // the visible text
  marks: Range[];      // inline formatting as (start, end, bold/italic/link) ranges
}

// The document state every client holds
interface DocState {
  blocks: Map<string, Block>;   // id -> block
  order: string[];              // block order as id list
  version: number;              // how many ops we've applied
}

// Operations: the only way state ever changes
type Op =
  | { type: "insertText", blockId: string, index: number, text: string }
  | { type: "deleteText", blockId: string, index: number, len: number }
  | { type: "insertBlock", afterId: string | null, id: string, block: Block }
  | { type: "format", blockId: string, start: number, end: number, mark: string };

Three properties matter more than the exact shape:

  • Ops are intent, not diffs. "insert 'hello' at index 12 in block b7" survives other people editing elsewhere; an HTML diff does not.
  • The log is append-only. New ops never change old ops. This is what makes offline queues, undo, and version history all the same mechanism (replay or truncate the log).
  • The text is a projection. Render = fold ops into state. The same insight as deriving a flat list from a comment tree โ€” store the minimal truth, derive the rest.

The editor pipeline: keypress โ†’ produce op โ†’ apply locally (optimistically) โ†’ append to log โ†’ broadcast โ†’ receive remote ops โ†’ apply. One apply function for local and remote ops guarantees convergence code paths don't fork.

The sync problem: OT vs CRDT, for real

Two ops race: Alice inserts "X" at index 3 while Bob deletes index 1. Applied raw in arrival order, results diverge. Everything in collaborative sync is about solving this one problem. There are two real families:

OT (Operational Transformation): transform ops against history

The idea: when op B arrives after concurrent op A, transform B to account for A โ€” shift its index past A's insertion, or adjust past A's deletion.

// Classic OT: transform b against concurrent a, both targeting the same state
function transform(b, a) {
  if (b.type === "insertText" && a.type === "insertText") {
    // both inserted at some index: shift b if a landed at/before it
    if (a.index < b.index || (a.index === b.index && a.site < b.site)) {
      return { ...b, index: b.index + a.text.length };
    }
    return b;
  }
  if (b.type === "insertText" && a.type === "deleteText") {
    if (a.index + a.len <= b.index) {
      return { ...b, index: b.index - a.len }; // deletion was fully before b
    }
    if (a.index >= b.index) return b;          // fully after โ€” no shift
    return { ...b, index: a.index };           // deletion spans b's index
  }
  // ... delete-vs-delete, delete-vs-insert, and the block-level cases
}

The catch is the diamond problem: with N concurrent ops you need transformed versions of transformed versions, and every pairwise case must be exactly right or documents silently diverge. In practice, centralize the ordering: a server receives ops per doc room, stamps a total order, transforms every client's op once against its history, and broadcasts. Clients are dumb; the server is the sequencer (this is the Google Wave / ShareDB architecture). OT is fine when you control a per-room server and concurrency per doc is modest.

CRDT: make merge commutative, skip the server ordering

The idea: give every operation an identity (site id + lamport clock), give every character a unique id, and design the merge so applying ops in any order yields the same document. No transform function, no central sequencer needed for correctness โ€” any relay (or peer) works.

The workhorse is a sequence CRDT (Yjs/YATA, RGA): insertion carries a reference โ€” left origin (the id of the character you're after) and clock โ€” so concurrent inserts land deterministically ordered on every replica. Deletes are tombstones: mark the character dead, never splice it out, so a delete of "index 3" stays meaningful while concurrent inserts shift indices.

// Yjs-flavored sync: state vectors answer "what do you have that I don't"
// Each replica tracks clock per site: { siteId: opCount }
function encodeStateVector(doc) {
  return doc.sites.map(s => [s.id, s.clock]);   // what I've seen from you
}
// Reconnect handshake: send vector, get back the missing ops, apply
socket.send({ type: "sync-step-1", stateVector: encodeStateVector(doc) });
// Peer replies with every op after my vector โ€” incremental, since first load

What to actually say in an interview: OT transforms operations; CRDTs pre-solve the transforms into the data structure. OT keeps a simpler document but needs a sequencer and gets subtly wrong easily. CRDTs make documents heavier (tombstones grow; mitigated by snapshot + GC) but merges are order-independent, sync is naturally incremental via state vectors, and mature libraries (Yjs, Automerge) have eaten the hard parts. Google Docs shipped OT in 2010; most greenfield products today build on Yjs. Picking Yjs and explaining why (battle-tested YATA, binary updates, awareness protocol bundled, works over any transport) is a stronger senior signal than hand-rolling an OT matrix.

Presence: cursors, selections, awareness

Presence โ€” who's here, where are their cursors โ€” must not go through the op log. It's ephemeral, high-frequency, and losing frames is harmless. Sync it on a separate lightweight channel (often the same WebSocket, different message type):

// throttle to ~1 per animation frame; send intent, not raw mouse moves
let pending = null;
editor.on("selectionChange", sel => { pending = sel; });
setInterval(() => {
  if (pending) {
    socket.send({ type: "presence", user: me,
      anchor: pending.anchor, head: pending.head });  // char ids, not offsets!
    pending = null;
  }
}, 80);
// receivers: colored overlay rectangles computed from anchor/head positions
// + heartbeat + timeout to garbage-collect departed users' cursors

The critical detail: presence positions are character ids, not numeric offsets. If you broadcast "offset 42," every concurrent edit before 42 makes your cursor point somewhere else on my screen. Anchor to an id (or id + bias like "before/after") and each client resolves it against its own state โ€” the same trick that makes undo stable. Heartbeats every few seconds with a ~10s timeout clean up the ghosts.

Rendering: the projection pipeline

Applying an op must not re-render the document. The pipeline is: ops land โ†’ apply to state (cheap, pure) โ†’ mark dirty blocks โ†’ flush to DOM in one requestAnimationFrame batch โ†’ update only changed text nodes. For very large docs, virtualize blocks โ€” render only the viewport neighborhood, exactly like a data table virtualizes rows โ€” and measure with placeholder heights if blocks vary.

One sharp edge: because the browser owned the caret, after applying remote ops you may need to rescue the local selection if its anchor region shifted โ€” which you can do precisely because positions are id-anchored, not numeric.

And the IME trap from the naive answer has one real answer: never rewrite the DOM under an active composition. Detect compositionstart/compositionend, buffer ops generated mid-composition, and only integrate them once it ends. This is the documented reason ProseMirror "hides" the DOM during composition โ€” and naming it is a strong senior tell.

Undo: per-user, not a global stack

"Ctrl+Z pops the last op" is wrong the moment two people type: Alice undoes and reverts Bob's sentence. Real semantics: undo my last op, wherever it sits in the log โ€” and do it by appending a new op that inverts it against everything that happened since (not by truncating the log, which would revert others' work too).

This is why op-based undo needs the same transform machinery as sync โ€” inverting an insert after someone deleted around it means re-computing positions. Yjs sidesteps it by tracking transactions and linking insertions/deletions structurally, which is why its UndoManager just works in collaborative sessions. If you've designed undo/redo as command-vs-snapshot before, note the twist: in a collaborative editor, the snapshot approach loses (you can't snapshot a moving document per user), and the command approach must transform against concurrent ops. Cross-reference your own undo/redo design and say which parts survive multiplayer.

Offline: the op log earns its keep

Offline is where the architecture pays out, and it's the same three pieces as the email client's outbox, reused:

  1. Persist the op log + snapshots in IndexedDB as you go. Reload the tab mid-edit, go offline, crash โ€” nothing is lost.
  2. Queue outbound ops while disconnected. The editor keeps working against local state; ops accumulate in a durable local queue with per-op ids (idempotency, so a retry after a half-dropped connection can't double-apply).
  3. Reconcile on reconnect with state vectors. Send your vector; receive everything you missed; apply remote ops; then flush your queued ops (in a CRDT world order doesn't matter โ€” that's the whole point). Conflicts don't "fail" โ€” they merge, deterministically, everywhere.

Version history falls out for free: snapshots every N ops plus the op log between snapshots is the history โ€” restore a version by projecting the log up to that point.

A11y in one paragraph

An editor with its own model must own the accessibility contract too: role="textbox" with aria-multiline, a documented keyboard map, and aria-live announcements for collaborative events ("Priya joined", "3 comments resolved") so screen-reader users aren't surprised by text mutating under their cursor. Remote presence is never exposed as focusable content. Real-time collaborative a11y is an open problem โ€” saying that beats pretending it's solved.

Interview gotchas โ€” the quick-fire round

  • "Just use contentEditable" is the wrong answer, but "avoid the DOM" is too โ€” you still render to it; you just own the state. Framing is the signal.
  • Character ids, not offsets, for every positional thing: cursors, selections, comments, format ranges. Say it once, apply it everywhere.
  • Tombstones are why deletes in CRDTs don't break concurrent inserts โ€” and why docs need snapshot + GC or they bloat forever.
  • The server still exists in a CRDT world: it does auth, rooms, fan-out, and snapshot persistence; it just isn't needed for correctness of merging.
  • Fan-out per doc: Redis pub/sub per room or a dedicated presence service; a doc room is exactly the chat-application room problem with bigger payloads.
  • Never mutate the DOM during IME composition โ€” CJK input breaks invisibly and your bug reports will be in a language you can't read.
  • Undo is per-user and transform-based; a global undo stack reverts other people's work โ€” the most common interview mistake.
  • Large pastes should be one op (or one transaction), not one op per character โ€” or the log, sync, and undo all choke.
  • Reconnection must be idempotent end-to-end: duplicate op delivery is normal over WebSocket reconnects; dedupe by op id.

Putting it together

A production collaborative editor design:

  • Data model: block-based doc mutated only by typed ops; append-only log; text is a projection; ids everywhere, never offsets
  • Sync: CRDT sequence (Yjs/YATA) for order-independent merge and incremental state-vector sync โ€” or centralized OT if you own a per-room sequencer and want simpler documents
  • Presence: separate ephemeral channel, id-anchored cursors, throttled to rAF, heartbeat + timeout GC
  • Rendering: dirty-block projection with rAF batching, block virtualization for huge docs, composition-safe DOM writes
  • Undo: per-user, log-appending inverse ops transformed against concurrent history
  • Offline: IndexedDB op log + durable idempotent outbox + state-vector reconciliation; version history falls out of snapshots
  • Transport: WebSocket rooms (auth, fan-out, snapshots), dedupe by op id

The text UI is the easy 20%. The op log โ€” its merge semantics, its id-anchored positions, its reuse as undo queue, outbox, and version history โ€” is the product, and the answer.

The Series: Frontend System Design Interview Questions

Donate
ยฉ 2024, Built with Gatsby