Skip to the solution
HardSystem Design15 min read

How would you design a collaborative editor like Google Docs?

481 views

The problem

Question presented to candidate: "Design a collaborative document editor like Google Docs. Several people type in the same document at the same time and everyone sees everyone else's changes within a fraction of a second, with cursors. Edits must never be lost, and everyone must end up with the same document. How does it work?"

What a strong answer should cover:

  • Why naive "send my edit position to everyone" diverges when edits are concurrent, and the goal: convergence while keeping each user's intent
  • Operational transformation (OT): a central server orders operations into a revision log and transforms each incoming op against the ops it has not seen; clients keep one pending op and transform incoming ops against it
  • CRDTs: every character gets a unique id, inserts reference neighbours by id, deletes leave tombstones, so replicas converge in any delivery order (good for offline and peer-to-peer) at a metadata cost
  • Architecture: WebSocket to a per-document session server (all editors of one doc on one owner), durable op log plus periodic snapshots, presence and cursors on a separate lossy channel
  • Capacity and limits: ops per second, log growth, editors per document; what breaks at 10x

Clarifying questions expected:

  • Plain text or rich text with formatting, tables and comments? Is offline editing required?
  • How many concurrent editors per document, and how many open documents at peak?
  • Do we need version history and named versions?

Code / implementation expected: Optional โ€” the design; the example shows naive replay diverging, server-ordered OT converging, and a CRDT converging in every delivery order.

  • collaboration
  • crdt
  • real-time

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

15 min read ยท 15 sections

Read the code

Collaborative editing: naive replay diverges, server-ordered OT converges, an RGA-style CRDT converges in all 120 orders
// Collaborative editing: naive replay diverges, OT with a central server converges, an RGA-style CRDT converges in any order.
// Ops are single characters: { t: "ins", pos, ch, site } | { t: "del", pos, site } | { t: "noop" }.
function apply(doc, op) {
  if (op.t === "ins") return doc.slice(0, op.pos) + op.ch + doc.slice(op.pos);
  if (op.t === "del") return doc.slice(0, op.pos) + doc.slice(op.pos + 1);
  return doc;
}
// transform(a, b): rewrite a so it can be applied after b (both were made on the same document).
function transform(a, b) {
  if (a.t === "noop" || b.t === "noop") return a;
  if (b.t === "ins") {
    if (b.pos < a.pos || (b.pos === a.pos && (a.t === "del" || b.site < a.site))) return { ...a, pos: a.pos + 1 };
    return a;
  }
  if (b.pos < a.pos) return { ...a, pos: a.pos - 1 };
  if (b.pos === a.pos && a.t === "del") return { t: "noop" }; // both deleted the same character
  return a;
}
const show = (op) => op.t === "ins" ? "ins(" + op.pos + ",'" + op.ch + "')" : op.t === "del" ? "del(" + op.pos + ")" : "noop";

const base = "cat";
const A = { t: "ins", pos: 1, ch: "h", site: 1 }, B = { t: "del", pos: 0, site: 2 }, C = { t: "ins", pos: 3, ch: "s", site: 3 };
console.log("base '" + base + "'; A " + show(A) + ", B " + show(B) + ", C " + show(C) + ", all made concurrently on rev 0");

const naiveA = apply(apply(apply(base, A), B), C), naiveB = apply(apply(apply(base, B), A), C), naiveC = apply(apply(apply(base, C), A), B);
console.log("naive replay, each site applies its own op first: A '" + naiveA + "', B '" + naiveB + "', C '" + naiveC + "'");

// Central server: keeps a log, transforms each incoming op against everything committed since its base revision.
const log = [];
function serverReceive(op, baseRev) {
  let t = op;
  for (let r = baseRev; r < log.length; r++) t = transform(t, log[r]);
  log.push(t);
  return t;
}
// Client: applies its own op at once, keeps it pending until the server acknowledges it.
function makeClient(name, op) { return { name, doc: apply(base, op), pending: op }; }
function clientReceive(c, op, fromSelf) {
  if (fromSelf) { c.pending = null; return; }
  let remote = op;
  if (c.pending) { const p = c.pending; remote = transform(op, p); c.pending = transform(p, op); }
  c.doc = apply(c.doc, remote);
}
const clients = [makeClient("A", A), makeClient("B", B), makeClient("C", C)];
const arrival = [B, C, A]; // the network delivers to the server in this order
const committed = arrival.map((op) => serverReceive(op, 0));
console.log("server log: " + committed.map(show).join(", "));
for (let r = 0; r < committed.length; r++) for (const c of clients) clientReceive(c, committed[r], arrival[r] === [A, B, C][clients.indexOf(c)]);
console.log("OT after broadcast: " + clients.map((c) => c.name + " '" + c.doc + "'").join(", "));
const same = { t: "del", pos: 2, site: 1 }, same2 = { t: "del", pos: 2, site: 2 };
console.log("both delete 't' at once: second becomes " + show(transform(same2, same)) + ", result '" + apply(apply(base, same), transform(same2, same)) + "'");

// CRDT (RGA style): every character has a unique id [counter, site] and is inserted after a parent id; delete = tombstone.
function crdtApply(doc, op) {
  if (op.t === "del") { doc.find((e) => e.id[0] === op.id[0] && e.id[1] === op.id[1]).dead = true; return; }
  let i = op.after === null ? 0 : doc.findIndex((e) => e.id[0] === op.after[0] && e.id[1] === op.after[1]) + 1;
  // skip siblings with a bigger id, so concurrent inserts at one spot end in the same order everywhere
  while (i < doc.length && (doc[i].id[0] > op.id[0] || (doc[i].id[0] === op.id[0] && doc[i].id[1] > op.id[1]))) i++;
  doc.splice(i, 0, { id: op.id, ch: op.ch, dead: false });
}
const text = (doc) => doc.filter((e) => !e.dead).map((e) => e.ch).join("");
function fresh() { const d = []; crdtApply(d, { t: "ins", id: [1, 0], after: null, ch: "c" }); crdtApply(d, { t: "ins", id: [2, 0], after: [1, 0], ch: "a" }); crdtApply(d, { t: "ins", id: [3, 0], after: [2, 0], ch: "t" }); return d; }
const ops = [
  { t: "ins", id: [4, 1], after: [1, 0], ch: "h" }, // A: h after c
  { t: "del", id: [1, 0] },                         // B: delete c
  { t: "ins", id: [4, 3], after: [3, 0], ch: "s" }, // C: s after t
  { t: "ins", id: [5, 1], after: [2, 0], ch: "X" }, // A: X after a
  { t: "ins", id: [5, 3], after: [2, 0], ch: "Y" }, // C: Y after a, concurrent with X
];
function perms(a) { if (a.length <= 1) return [a]; const out = []; a.forEach((x, i) => perms([...a.slice(0, i), ...a.slice(i + 1)]).forEach((p) => out.push([x, ...p]))); return out; }
const results = new Map();
for (const order of perms([0, 1, 2, 3, 4])) { const d = fresh(); for (const k of order) crdtApply(d, ops[k]); const t = text(d); results.set(t, (results.get(t) || 0) + 1); }
console.log("CRDT: applied the 5 ops in all 120 orders -> " + [...results].map(([t, n]) => "'" + t + "' x" + n).join(", "));
const d = fresh(); for (const op of ops) crdtApply(d, op);
console.log("CRDT state: " + d.length + " elements for " + text(d).length + " visible characters (" + d.filter((e) => e.dead).length + " tombstone)");

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 85 of 106 in System design

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

Up nextHard

How would you design AI inference infra with GPU queue and KV-cache?

Continue