{"id":"design-collaborative-document","title":"Design a collaborative document editor (like Google Docs or Figma)","sections":[{"id":"overview","title":"Overview","html":"<p>A collaborative editor lets several people change the same document at the same time and see each other's changes within a fraction of a second. There is no save button and no \"someone else is editing this file\" lock. Each person's keystrokes appear on their own screen at once, other people's edits stream in, and colored cursors show who is working where.</p>\n<p>The hard part is concurrency. When two people edit the same sentence at nearly the same moment, each one's edit was made against a version of the text that didn't include the other's. The system has to merge both edits so that every screen ends up showing the same text, without locking the document and without throwing either edit away.</p>\n<p>The primer lists \"Design Google docs\" in its appendix, with links to old Google Code projects that no longer exist. This chapter covers how these editors work: the two main merge techniques, <strong>Operational Transformation (OT)</strong> and <strong>Conflict-free Replicated Data Types (CRDTs)</strong>; the WebSocket pipeline that carries edits; live cursors and presence; and how the document is stored. It builds on the primer's <a href=\"/learn/database\">database</a> and <a href=\"/learn/communication\">communication</a> chapters and on the extra chapters on <a href=\"/learn/realtime\">real-time updates</a>, <a href=\"/learn/consensus\">consensus</a> and <a href=\"/learn/reliability-patterns\">reliability patterns</a>.</p>","headings":[]},{"id":"the-core-concurrency-problem","title":"The core concurrency problem","html":"<h3 id=\"the-core-concurrency-problem\">The core concurrency problem</h3>\n<p>To see why this is hard, treat the text as an array of characters and have each client broadcast its edits as raw index operations.</p>\n<p>Alice and Bob both start with <code>\"CAT\"</code>. At about the same moment:</p>\n<ul>\n<li>Alice types <code>H</code> at index 1, making <code>\"CHAT\"</code>. Her operation is <code>insert('H', 1)</code>.</li>\n<li>Bob deletes the character at index 2 (the <code>T</code>), making <code>\"CA\"</code>. His operation is <code>delete(2)</code>.</li>\n</ul>\n<p>Each client applies its own edit at once, then receives the other's and applies it as is.</p>\n<figure class=\"diagram\" data-text=\"Start: &#x22;CAT&#x22; -> Alice: insert (&#x27;H&#x27;, 1) → &#x22;CHAT&#x22;; Start: &#x22;CAT&#x22; -> Bob: delete(2) → &#x22;CA&#x22;; Alice: insert (&#x27;H&#x27;, 1) → &#x22;CHAT&#x22; -> Alice applies delete(2) → &#x22;CHT&#x22; (gets Bob&#x27;s op); Bob: delete(2) → &#x22;CA&#x22; -> Bob applies insert (&#x27;H&#x27;, 1) → &#x22;CHA&#x22; (gets Alice&#x27;s op)\"><div class=\"diagram-scroll\"><svg class=\"dg\" viewBox=\"0 0 640 320\" width=\"640\" role=\"img\" aria-label=\"Applying concurrent index operations as they arrive\"><defs><marker id=\"dg-arrow\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\"><path class=\"dg-arrowhead\" d=\"M0,1 L9,5 L0,9 z\"></path></marker></defs><line class=\"dg-edge\" x1=\"266\" y1=\"69\" x2=\"142\" y2=\"131\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"374\" y1=\"69\" x2=\"498\" y2=\"131\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"80\" y1=\"191\" x2=\"80\" y2=\"251\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"560\" y1=\"191\" x2=\"560\" y2=\"251\" marker-end=\"url(#dg-arrow)\"></line><rect class=\"dg-node dg-note\" x=\"252\" y=\"18\" width=\"136\" height=\"48\" rx=\"8\"></rect><text class=\"dg-label dg-muted\" text-anchor=\"middle\"><tspan x=\"320\" y=\"46.5\">Start: \"CAT\"</tspan></text><rect class=\"dg-node dg-note\" x=\"12\" y=\"136\" width=\"136\" height=\"52\" rx=\"8\"></rect><text class=\"dg-label dg-muted\" text-anchor=\"middle\"><tspan x=\"80\" y=\"158.5\">Alice: insert</tspan><tspan x=\"80\" y=\"174.5\">('H', 1) → \"CHAT\"</tspan></text><rect class=\"dg-node dg-note\" x=\"492\" y=\"136\" width=\"136\" height=\"52\" rx=\"8\"></rect><text class=\"dg-label dg-muted\" text-anchor=\"middle\"><tspan x=\"560\" y=\"158.5\">Bob: delete(2)</tspan><tspan x=\"560\" y=\"174.5\">→ \"CA\"</tspan></text><rect class=\"dg-node dg-note\" x=\"12\" y=\"256\" width=\"136\" height=\"52\" rx=\"8\"></rect><text class=\"dg-label dg-muted\" text-anchor=\"middle\"><tspan x=\"80\" y=\"278.5\">Alice applies</tspan><tspan x=\"80\" y=\"294.5\">delete(2) → \"CHT\"</tspan></text><rect class=\"dg-node dg-note\" x=\"492\" y=\"256\" width=\"136\" height=\"52\" rx=\"8\"></rect><text class=\"dg-label dg-muted\" text-anchor=\"middle\"><tspan x=\"560\" y=\"278.5\">Bob applies insert</tspan><tspan x=\"560\" y=\"294.5\">('H', 1) → \"CHA\"</tspan></text><rect class=\"dg-edge-bg\" x=\"34.7\" y=\"212\" width=\"90.6\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"80\" y=\"225\" text-anchor=\"middle\">gets Bob's op</text><rect class=\"dg-edge-bg\" x=\"508.5\" y=\"212\" width=\"103\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"560\" y=\"225\" text-anchor=\"middle\">gets Alice's op</text></svg></div><figcaption>Applying concurrent index operations as they arrive</figcaption></figure>\n<ul>\n<li>Alice ends with <strong><code>\"CHT\"</code></strong>. Her insert shifted <code>A</code> into index 2, so Bob's <code>delete(2)</code> removed the <code>A</code> instead of the <code>T</code>.</li>\n<li>Bob ends with <strong><code>\"CHA\"</code></strong>, which is what both of them meant.</li>\n</ul>\n<p>The two copies have <strong>diverged</strong>, and nothing will bring them back together. An index only means something relative to the exact text it was computed against, and the two clients applied the operations in different orders. OT and CRDTs are two ways to fix this.</p>","headings":[{"id":"the-core-concurrency-problem","title":"The core concurrency problem","depth":3}]},{"id":"solution-1-operational-transformation-ot","title":"Solution 1: Operational Transformation (OT)","html":"<h3 id=\"solution-1-operational-transformation-ot\">Solution 1: Operational Transformation (OT)</h3>\n<p>Operational Transformation was introduced by Clarence Ellis and Simon Gibbs in 1989, for the GROVE group editor. The client-server form most products use comes from the Jupiter system at Xerox PARC (1995). Google Wave was built on it, and Google Docs uses OT with a central server.</p>\n<p>In OT, edits stay as index operations such as <code>insert(char, pos)</code> and <code>delete(pos)</code>. A client applies its own edit locally at once, so typing never waits for the network. It then sends the operation to the server, tagged with the revision number of the document it was made against.</p>\n<p>If the server has accepted other operations since that revision, it <strong>transforms</strong> the incoming operation against each of them before applying it. A transformation function takes two concurrent operations and returns a version of each that can be applied after the other:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\" style=\"background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span>transform(a, b) → (a′, b′)    such that    apply(apply(doc, a), b′) = apply(apply(doc, b), a′)</span></span></code></pre>\n<p>For text, the rules mostly shift positions. An insert before your position moves you right by one; a delete before your position moves you left by one. When two inserts land at the same position, a fixed tie-break (for example, the lower client ID goes first) decides which comes first, so every replica makes the same choice.</p>\n<h4 id=\"how-ot-resolves-the-example\">How OT resolves the example</h4>\n<ol>\n<li>The server receives Alice's <code>insert('H', 1)</code> first, against revision 0. Nothing has happened since, so it applies it as revision 1. The server's text is <code>\"CHAT\"</code>.</li>\n<li>The server receives Bob's <code>delete(2)</code>, also against revision 0. Revision 1 has happened since.</li>\n<li>Revision 1 inserted a character at index 1, before Bob's index 2, so the server shifts Bob's operation right by one: <code>delete(2)</code> becomes <code>delete(3)</code>.</li>\n<li><code>delete(3)</code> removes the <code>T</code>. The server's text is <code>\"CHA\"</code>, as revision 2.</li>\n<li>The server sends <code>delete(3)</code> to Alice, whose text is <code>\"CHAT\"</code>, and she ends with <code>\"CHA\"</code>. It sends <code>insert('H', 1)</code> to Bob. Bob's client transforms it against his own <code>delete(2)</code>: the insert is at index 1, before the deleted position, so it stays <code>insert('H', 1)</code>, and Bob also ends with <code>\"CHA\"</code>.</li>\n</ol>\n<p>Both clients converge on <code>\"CHA\"</code>.</p>\n<p>On the client side, the usual protocol (from Jupiter, and used by Google Wave) keeps at most one operation in flight. While waiting for the server's ack, the client buffers further keystrokes into a pending operation. Each operation the server sends is transformed against the in-flight and pending operations before it is applied locally. This keeps the client's bookkeeping small: it only ever has to reconcile against one server history.</p>\n<h4 id=\"strengths-and-weaknesses-of-ot\">Strengths and weaknesses of OT</h4>\n<ul>\n<li><strong>Strengths:</strong> the document is just text plus a revision log, with no extra data per character. With a central server that puts every operation in one order, the transformation functions only have to satisfy the simpler of OT's two correctness conditions. That combination is well understood and runs some of the largest editors in production.</li>\n<li><strong>Weaknesses:</strong> transformation functions are easy to get subtly wrong, and the number of cases grows with every new operation type (formatting, tables, lists, comments). Several OT algorithms published for fully peer-to-peer use were later shown not to converge in some cases, because without a central order the functions must also satisfy a second, much harder condition. In practice that's why OT systems almost always route every edit through a server, which then becomes required for collaboration: two clients can't merge with each other directly.</li>\n</ul>","headings":[{"id":"solution-1-operational-transformation-ot","title":"Solution 1: Operational Transformation (OT)","depth":3},{"id":"how-ot-resolves-the-example","title":"How OT resolves the example","depth":4},{"id":"strengths-and-weaknesses-of-ot","title":"Strengths and weaknesses of OT","depth":4}]},{"id":"solution-2-conflict-free-replicated-data-types-crdts","title":"Solution 2: Conflict-free Replicated Data Types (CRDTs)","html":"<h3 id=\"solution-2-conflict-free-replicated-data-types-crdts\">Solution 2: Conflict-free Replicated Data Types (CRDTs)</h3>\n<p>A CRDT avoids transformation by never using positions that shift. Instead of \"insert at index 1\", an insert says \"insert this character, which has this permanent ID, right after the character with that ID\". IDs never change, so an operation means the same thing no matter what else has happened to the document. Libraries such as <strong>Yjs</strong> and <strong>Automerge</strong> implement CRDTs for text and JSON-like documents.</p>\n<p>There are two main ways to give characters stable identities:</p>\n<ul>\n<li><strong>Dense position identifiers</strong> (Logoot, LSEQ). Each character's ID is a position that sorts between its neighbours. To insert between positions 1 and 2, a client picks something in between, like 1.5. Real implementations use variable-length lists of digits, not floating-point numbers, because repeated halving runs out of float precision after about 50 inserts in the same spot. The client's ID is appended to break ties.</li>\n<li><strong>Unique ID plus left neighbour</strong> (RGA, and YATA, the algorithm behind Yjs). Each character gets an ID made of <code>(client ID, counter)</code> and records the ID of the character it was inserted after. Concurrent inserts after the same character are ordered by a deterministic rule on their IDs.</li>\n</ul>\n<figure class=\"diagram\" data-text=\"&#x27;C&#x27; id 1 -> &#x27;H&#x27; id 1.5; &#x27;H&#x27; id 1.5 -> &#x27;A&#x27; id 2; &#x27;A&#x27; id 2 -> &#x27;T&#x27; id 3\"><div class=\"diagram-scroll\"><svg class=\"dg\" viewBox=\"0 0 640 80\" width=\"640\" role=\"img\" aria-label=\"Each character has a permanent position ID\"><defs><marker id=\"dg-arrow\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\"><path class=\"dg-arrowhead\" d=\"M0,1 L9,5 L0,9 z\"></path></marker></defs><line class=\"dg-edge\" x1=\"151\" y1=\"42\" x2=\"167\" y2=\"42\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"311\" y1=\"42\" x2=\"327\" y2=\"42\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"471\" y1=\"42\" x2=\"487\" y2=\"42\" marker-end=\"url(#dg-arrow)\"></line><rect class=\"dg-node dg-note\" x=\"12\" y=\"16\" width=\"136\" height=\"52\" rx=\"8\"></rect><text class=\"dg-label dg-muted\" text-anchor=\"middle\"><tspan x=\"80\" y=\"38.5\">'C'</tspan><tspan x=\"80\" y=\"54.5\">id 1</tspan></text><rect class=\"dg-node dg-note\" x=\"172\" y=\"16\" width=\"136\" height=\"52\" rx=\"8\"></rect><text class=\"dg-label dg-muted\" text-anchor=\"middle\"><tspan x=\"240\" y=\"38.5\">'H'</tspan><tspan x=\"240\" y=\"54.5\">id 1.5</tspan></text><rect class=\"dg-node dg-note\" x=\"332\" y=\"16\" width=\"136\" height=\"52\" rx=\"8\"></rect><text class=\"dg-label dg-muted\" text-anchor=\"middle\"><tspan x=\"400\" y=\"38.5\">'A'</tspan><tspan x=\"400\" y=\"54.5\">id 2</tspan></text><rect class=\"dg-node dg-note\" x=\"492\" y=\"16\" width=\"136\" height=\"52\" rx=\"8\"></rect><text class=\"dg-label dg-muted\" text-anchor=\"middle\"><tspan x=\"560\" y=\"38.5\">'T'</tspan><tspan x=\"560\" y=\"54.5\">id 3</tspan></text></svg></div><figcaption>Each character has a permanent position ID</figcaption></figure>\n<h4 id=\"how-a-crdt-resolves-the-example\">How a CRDT resolves the example</h4>\n<p>Start with <code>C</code> = 1, <code>A</code> = 2, <code>T</code> = 3.</p>\n<ol>\n<li>Alice inserts <code>H</code> between IDs 1 and 2, giving it ID 1.5 (tagged with her client ID). She sends \"insert <code>H</code> at 1.5\".</li>\n<li>Bob deletes the <code>T</code>. He sends \"delete ID 3\".</li>\n<li>Alice receives \"delete ID 3\" and hides the <code>T</code>: <code>\"CHA\"</code>.</li>\n<li>Bob receives \"insert <code>H</code> at 1.5\" and places it between 1 and 2: <code>\"CHA\"</code>.</li>\n</ol>\n<p>Neither operation had to be rewritten, and the order they arrived in didn't matter.</p>\n<h4 id=\"the-mechanics\">The mechanics</h4>\n<ol>\n<li><strong>Deterministic tie-breaking.</strong> If Bob also inserts a letter between 1 and 2 at the same moment and happens to pick the same position, the two IDs compare as <code>(1.5, alice)</code> and <code>(1.5, bob)</code>. Every replica sorts them the same way.</li>\n<li><strong>Deletes leave tombstones.</strong> A deleted character is marked deleted and hidden, but its ID stays in the structure. Another client may concurrently insert \"after ID 3\", and that insert still needs ID 3 to find its place. Tombstones accumulate over time, so implementations compact them once every replica is known to have seen the delete, or store deleted runs very compactly.</li>\n<li><strong>Order-independent merging.</strong> Concurrent operations commute: applying them in any order gives the same result. Operation-based CRDTs still expect each client's own operations in causal order (you can't delete a character before you've received its insert), which a sync protocol provides by buffering an operation until what it depends on has arrived. The guarantee is called <strong>strong eventual consistency</strong>: any two replicas that have received the same set of operations are in the same state.</li>\n<li><strong>Metadata cost.</strong> Each character carries an ID and a neighbour reference, many times the size of the character itself. Yjs keeps this manageable by storing a run of characters typed in sequence by one client as a single item.</li>\n</ol>\n<p>Converging is not the same as preserving intent. If Alice types \"Hello\" and Bob types \"World\" at the same spot, some CRDT algorithms can interleave the letters into something like \"HWeolrllod\". Newer algorithms are designed to keep each run of typing together, and Martin Kleppmann's work on interleaving anomalies covers which ones do.</p>\n<h4 id=\"who-uses-what\">Who uses what</h4>\n<p>Google Docs uses OT with a central server. Figma's multiplayer system is a useful middle case: its engineers describe it as inspired by CRDTs but not a true CRDT. Figma's server is the central authority, which lets it drop some of the overhead a fully decentralized CRDT needs. Many CRDT-based products also run a server per document, for authentication, persistence and fan-out. The CRDT then isn't avoiding a server; it's making offline editing and merging much simpler.</p>","headings":[{"id":"solution-2-conflict-free-replicated-data-types-crdts","title":"Solution 2: Conflict-free Replicated Data Types (CRDTs)","depth":3},{"id":"how-a-crdt-resolves-the-example","title":"How a CRDT resolves the example","depth":4},{"id":"the-mechanics","title":"The mechanics","depth":4},{"id":"who-uses-what","title":"Who uses what","depth":4}]},{"id":"system-requirements-and-estimates","title":"System requirements and estimates","html":"<h3 id=\"system-requirements-and-estimates\">System requirements and estimates</h3>\n<h4 id=\"functional-requirements\">Functional requirements</h4>\n<ol>\n<li><strong>Multiplayer editing:</strong> several users edit the same document at once and see each other's changes in real time.</li>\n<li><strong>Presence and cursors:</strong> users see who else is in the document, with their cursors and selections.</li>\n<li><strong>Revision history:</strong> users can browse earlier versions and restore one.</li>\n<li><strong>Offline support:</strong> edits made while disconnected are merged when the client reconnects.</li>\n</ol>\n<h4 id=\"non-functional-requirements\">Non-functional requirements</h4>\n<ol>\n<li><strong>Typing latency:</strong> a keystroke shows on the typist's screen in the same frame, with no network round trip. Remote edits should show up for collaborators within about 100 ms in the same region, and within a few hundred milliseconds worldwide.</li>\n<li><strong>Convergence:</strong> once every client has received every edit, all copies are identical.</li>\n<li><strong>Durability:</strong> once the server acknowledges an edit, it must survive a browser crash or a server restart.</li>\n</ol>\n<h4 id=\"capacity-estimates\">Capacity estimates</h4>\n<p>These numbers are assumptions for a large product, not figures from any real one:</p>\n<ul>\n<li><strong>Documents:</strong> 5 million documents edited per day.</li>\n<li><strong>Concurrent sessions:</strong> at peak, 100,000 documents are open for editing, with an average of 3 people each. That's 100,000 × 3 = <strong>300,000 open WebSocket connections</strong>.</li>\n<li><strong>Edit rate:</strong> people mostly read, pause and think. Assume that at any moment one person per open document is typing, at 5 characters a second. That's 100,000 × 5 = <strong>500,000 operations a second</strong> coming in at peak.</li>\n<li><strong>Fan-out:</strong> each operation goes to the 2 other people in the document, so the servers send about 1,000,000 messages a second.</li>\n<li><strong>Bandwidth:</strong> at roughly 100 bytes per message including framing, that's 500,000 × 100 B = 50 MB/s in and 1,000,000 × 100 B = 100 MB/s out, about 0.8 Gbit/s outbound. That is modest for a whole fleet.</li>\n<li><strong>Gateways:</strong> if one gateway holds 50,000 connections, 300,000 ÷ 50,000 = 6 gateways. Run 10 or more, for headroom and so that one failure doesn't overload the rest.</li>\n<li><strong>Operation log:</strong> if the daily average is a quarter of peak, that's 125,000 ops/s × 86,400 s ≈ 10.8 billion operations a day, or about 2,200 per edited document. At 100 bytes each, that's about <strong>1.1 TB a day</strong> before compression. That growth is why the log is regularly compacted into snapshots.</li>\n</ul>\n<p>Network and connections are not the bottleneck. Each document's edits have to be put in order by one owner, so the design is shaped by routing every document to one coordinator and keeping the operation log durable.</p>","headings":[{"id":"system-requirements-and-estimates","title":"System requirements and estimates","depth":3},{"id":"functional-requirements","title":"Functional requirements","depth":4},{"id":"non-functional-requirements","title":"Non-functional requirements","depth":4},{"id":"capacity-estimates","title":"Capacity estimates","depth":4}]},{"id":"end-to-end-architecture","title":"End-to-end architecture","html":"<h3 id=\"end-to-end-architecture\">End-to-end architecture</h3>\n<figure class=\"diagram\" data-text=\"Alice <-> WebSocket gateways (WebSocket); Bob <-> WebSocket gateways (WebSocket); WebSocket gateways <-> Redis (presence) (presence); WebSocket gateways <-> Collab worker (one per doc) (edits); Collab worker (one per doc) -> Operation log (append); Operation log -> Snapshot worker; Snapshot worker -> Snapshots (S3 + Postgres) (snapshot)\"><div class=\"diagram-scroll\"><svg class=\"dg\" viewBox=\"0 0 800 445\" width=\"800\" role=\"img\" aria-label=\"Collaborative editor architecture\"><defs><marker id=\"dg-arrow\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\"><path class=\"dg-arrowhead\" d=\"M0,1 L9,5 L0,9 z\"></path></marker></defs><line class=\"dg-edge\" x1=\"118.7\" y1=\"71\" x2=\"198.7\" y2=\"131\" marker-end=\"url(#dg-arrow)\" marker-start=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"118.7\" y1=\"253\" x2=\"198.7\" y2=\"193\" marker-end=\"url(#dg-arrow)\" marker-start=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"281.3\" y1=\"131\" x2=\"352\" y2=\"78\" marker-end=\"url(#dg-arrow)\" marker-start=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"281.3\" y1=\"193\" x2=\"358.7\" y2=\"251\" marker-end=\"url(#dg-arrow)\" marker-start=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"471\" y1=\"282\" x2=\"487\" y2=\"282\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge dg-dashed\" x1=\"631\" y1=\"282\" x2=\"647\" y2=\"282\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"720\" y1=\"309\" x2=\"720\" y2=\"366\" marker-end=\"url(#dg-arrow)\"></line><rect class=\"dg-node dg-client\" x=\"12\" y=\"18\" width=\"136\" height=\"48\" rx=\"24\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"80\" y=\"46.5\">Alice</tspan></text><rect class=\"dg-node dg-client\" x=\"12\" y=\"258\" width=\"136\" height=\"48\" rx=\"24\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"80\" y=\"286.5\">Bob</tspan></text><rect class=\"dg-node dg-box\" x=\"172\" y=\"136\" width=\"136\" height=\"52\" rx=\"8\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"240\" y=\"158.5\">WebSocket</tspan><tspan x=\"240\" y=\"174.5\">gateways</tspan></text><path class=\"dg-node dg-store\" d=\"M332,18 a68,7 0 0,1 136,0 v48 a68,7 0 0,1 -136,0 z\"></path><path class=\"dg-line\" d=\"M332,18 a68,7 0 0,0 136,0\"></path><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"400\" y=\"41.5\">Redis</tspan><tspan x=\"400\" y=\"57.5\">(presence)</tspan></text><rect class=\"dg-node dg-box\" x=\"332\" y=\"256\" width=\"136\" height=\"52\" rx=\"8\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"400\" y=\"278.5\">Collab worker</tspan><tspan x=\"400\" y=\"294.5\">(one per doc)</tspan></text><rect class=\"dg-node dg-queue\" x=\"492\" y=\"258\" width=\"136\" height=\"48\" rx=\"6\"></rect><line class=\"dg-line\" x1=\"612\" y1=\"266\" x2=\"612\" y2=\"298\"></line><line class=\"dg-line\" x1=\"602\" y1=\"266\" x2=\"602\" y2=\"298\"></line><line class=\"dg-line\" x1=\"592\" y1=\"266\" x2=\"592\" y2=\"298\"></line><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"546\" y=\"286.5\">Operation log</tspan></text><rect class=\"dg-node dg-box\" x=\"652\" y=\"258\" width=\"136\" height=\"48\" rx=\"8\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"720\" y=\"286.5\">Snapshot worker</tspan></text><path class=\"dg-node dg-store\" d=\"M652,378 a68,7 0 0,1 136,0 v48 a68,7 0 0,1 -136,0 z\"></path><path class=\"dg-line\" d=\"M652,378 a68,7 0 0,0 136,0\"></path><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"720\" y=\"401.5\">Snapshots</tspan><tspan x=\"720\" y=\"417.5\">(S3 + Postgres)</tspan></text><rect class=\"dg-edge-bg\" x=\"125.8\" y=\"92\" width=\"65.8\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"158.7\" y=\"105\" text-anchor=\"middle\">WebSocket</text><rect class=\"dg-edge-bg\" x=\"125.8\" y=\"214\" width=\"65.8\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"158.7\" y=\"227\" text-anchor=\"middle\">WebSocket</text><rect class=\"dg-edge-bg\" x=\"286.9\" y=\"95.5\" width=\"59.6\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"316.7\" y=\"108.5\" text-anchor=\"middle\">presence</text><rect class=\"dg-edge-bg\" x=\"299.5\" y=\"213\" width=\"41\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"320\" y=\"226\" text-anchor=\"middle\">edits</text><rect class=\"dg-edge-bg\" x=\"455.4\" y=\"234\" width=\"47.2\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"479\" y=\"247\" text-anchor=\"middle\">append</text><rect class=\"dg-edge-bg\" x=\"690.2\" y=\"328.5\" width=\"59.6\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"720\" y=\"341.5\" text-anchor=\"middle\">snapshot</text></svg></div><figcaption>Collaborative editor architecture</figcaption></figure>\n<h4 id=\"component-breakdown\">Component breakdown</h4>\n<ol>\n<li><strong>WebSocket gateways.</strong> They hold long-lived connections to browsers, terminate TLS, answer heartbeats, and route each incoming message to the collaboration worker that owns its document. They keep no document state, so a dead gateway only costs its clients a reconnect.</li>\n<li><strong>Collaboration worker.</strong> Every open document has exactly one owning worker, chosen with <a href=\"/learn/consistent-hashing\">consistent hashing</a> or recorded in a registry with a lease. Only one owner may exist at a time: two workers each ordering edits for the same document would recreate the divergence problem, so ownership changes go through a lease or a <a href=\"/learn/consensus\">consensus</a>-backed lock. The owner:\n<ul>\n<li>loads the latest snapshot plus any later operations when the document is first opened;</li>\n<li>holds the live document in memory (the OT revision history or the CRDT structure);</li>\n<li>assigns each edit the next revision number, transforms it if needed (OT) or integrates it (CRDT), and appends it to the log;</li>\n<li>broadcasts the accepted edit to the gateways serving the document's other users.</li>\n</ul>\n</li>\n<li><strong>Operation log.</strong> An append-only log of accepted operations per document, in Kafka or a database table keyed by <code>(doc_id, revision)</code>. The worker acknowledges an edit to its sender only <strong>after</strong> the append is durable. Snapshots alone would lose the last 30 seconds of work on a crash; the log is what makes an acknowledged edit safe.</li>\n<li><strong>Snapshot worker.</strong> It reads the log and periodically writes a compressed snapshot of each document (for example every few hundred operations, or every 30 seconds while the document is being edited) to object storage, with its revision number and location in PostgreSQL. Opening a document loads the latest snapshot and replays the operations after it.</li>\n<li><strong>Redis for presence.</strong> Who is in the document, their color, and where their cursor is. This state is ephemeral and never goes into the log; see the presence section below.</li>\n</ol>\n<h4 id=\"revision-history\">Revision history</h4>\n<p>Revision history falls out of the log and the snapshots. Version N is the nearest snapshot at or before N plus the operations up to N. Products don't show every keystroke as a version: they group operations into named or time-based checkpoints, such as \"Alice, 10:42–10:51\". Restoring an old version is done as a new edit that changes the current text to match the old one. It is not a rollback of the log, because other people may be editing at that moment.</p>","headings":[{"id":"end-to-end-architecture","title":"End-to-end architecture","depth":3},{"id":"component-breakdown","title":"Component breakdown","depth":4},{"id":"revision-history","title":"Revision history","depth":4}]},{"id":"the-typing-and-sync-lifecycle","title":"The typing and sync lifecycle","html":"<h3 id=\"the-typing-and-sync-lifecycle\">The typing and sync lifecycle</h3>\n<p>What happens between Alice typing a letter and Bob seeing it, with OT:</p>\n<ol>\n<li><strong>Local render.</strong> Alice types <code>k</code>. Her editor applies it at once. Nothing waits for the network.</li>\n<li><strong>Send.</strong> If no earlier operation is waiting for an ack, the client sends this one at once over the WebSocket, tagged with its base revision: <code>{op: insert('k', 41), rev: 4}</code>. If one is in flight, the keystroke joins the pending buffer, which goes out as one operation when the ack arrives. Fast typing is batched this way without a fixed timer.</li>\n<li><strong>Order and transform.</strong> The collab worker checks for operations after revision 4, transforms Alice's edit against them, and assigns it revision 5.</li>\n<li><strong>Persist.</strong> The worker appends revision 5 to the operation log.</li>\n<li><strong>Broadcast and ack.</strong> The worker sends the transformed operation to everyone else in the document, and sends Alice an ack for revision 5. Her client now sends its pending buffer, if there is one.</li>\n<li><strong>Remote render.</strong> Bob's client transforms the incoming operation against any of his own unacknowledged edits, applies it, and redraws.</li>\n</ol>\n<p>Within one region, steps 2 to 6 take roughly one network round trip plus a durable append: tens of milliseconds.</p>\n<p>With a CRDT the flow is the same apart from step 3. The worker doesn't need to transform anything; it integrates the operation into its copy and assigns a sequence number for the log.</p>\n<h4 id=\"when-a-collab-worker-dies\">When a collab worker dies</h4>\n<p>Clients notice the dropped connection or a timeout. The document is assigned to a new worker, which loads the latest snapshot and replays the log to the last durable revision. Clients reconnect and resend every operation that was never acknowledged, each tagged with a client ID and client sequence number so that the new worker can drop any it already has. Because the old worker only acknowledged durable operations, nothing acknowledged is lost.</p>","headings":[{"id":"the-typing-and-sync-lifecycle","title":"The typing and sync lifecycle","depth":3},{"id":"when-a-collab-worker-dies","title":"When a collab worker dies","depth":4}]},{"id":"live-presence-cursors-and-selections","title":"Live presence: cursors and selections","html":"<h3 id=\"live-presence-cursors-and-selections\">Live presence: cursors and selections</h3>\n<p>Colored cursors and selections make collaboration feel live, but they are a different kind of data from the text:</p>\n<ul>\n<li><strong>Ephemeral.</strong> If a cursor update is lost, the next one corrects it. Cursor updates never go into the operation log or the database. They go through Redis pub/sub (or directly through the collab worker) to the other users of the document, and Redis holds the last known position with a short TTL so a newcomer sees everyone at once.</li>\n<li><strong>Anchored to the text, not the screen.</strong> A cursor is sent as a position in the document, not as <code>(x, y)</code> pixels, because each user's window width, font and zoom lay the text out differently. With OT it's an index transformed like any other position. With a CRDT it's a character ID, so it stays attached to the same character as text is inserted before it.</li>\n<li><strong>Throttled.</strong> Selection changes and mouse movement can fire far more often than anyone needs to see. Clients send at most around 20 cursor updates a second (one per 50 ms), and only when something changed. Without throttling, cursor traffic can easily exceed edit traffic.</li>\n<li><strong>Smoothed.</strong> The receiving client animates a remote cursor between updates, for example with a short CSS transition, so it glides instead of jumping.</li>\n</ul>","headings":[{"id":"live-presence-cursors-and-selections","title":"Live presence: cursors and selections","depth":3}]},{"id":"offline-editing-and-reconnection","title":"Offline editing and reconnection","html":"<h3 id=\"offline-editing-and-reconnection\">Offline editing and reconnection</h3>\n<p>Suppose someone edits on a plane and reconnects after making 200 edits while others made 1,000.</p>\n<ul>\n<li><strong>With OT,</strong> the client sends its 200 operations, based on an old revision. Each one is transformed against the 1,000 server operations it missed: about 200 × 1,000 = 200,000 transform calls, a few milliseconds of CPU. That works, and Google Docs supports offline editing. The costs are that the server must keep enough history to transform against, and that the transformation rules for rich content (tables, lists, formatting) must be correct for every combination that can happen in a long divergence.</li>\n<li><strong>With a CRDT,</strong> the client and server exchange <strong>state vectors</strong>: for each client ID, the highest counter each side has seen. Each side then sends the operations the other is missing, and both merge them. No history has to be transformed, and the merge would work the same way peer to peer.</li>\n</ul>\n<p>Either way the documents converge, but neither technique knows what the user meant. If two people rewrote the same paragraph separately for an hour, the merged result contains both sets of changes woven together and may read badly. Products handle this in the interface: after a long offline session they can highlight what changed, or offer the offline edits as suggestions for someone to accept.</p>","headings":[{"id":"offline-editing-and-reconnection","title":"Offline editing and reconnection","depth":3}]},{"id":"check-yourself","title":"Check yourself","html":"<h3 id=\"check-yourself\">Check yourself</h3>\n<details>\n<summary>Why can't a collaborative editor simply broadcast index operations like insert('H', 1) and apply them as they arrive?</summary>\n<p>An index only means something relative to the exact text it was computed against. If Alice inserts at index 1 while Bob deletes at index 2, Alice's insert shifts every later character right by one. When Bob's <code>delete(2)</code> reaches Alice, index 2 now holds a different character, so she deletes the wrong one. Bob applies Alice's insert to text without the <code>T</code>, which happens to be fine. The two copies end up different, and nothing later brings them back together.</p>\n</details>\n<details>\n<summary>What is the main difference between OT and CRDTs, and why do both kinds of product usually still run a server?</summary>\n<p>OT keeps edits as index operations and rewrites (transforms) each incoming operation against the concurrent ones it missed. Getting the transformation functions right is far easier when one server puts every operation in a single order, so OT systems route all edits through a server.</p>\n<p>A CRDT gives every character a permanent ID, so operations refer to IDs that never shift. Concurrent operations commute and can be applied in any order, with no transformation, and replicas can merge peer to peer. The price is per-character metadata and tombstones.</p>\n<p>Products using either one usually still run a server per document, for access control, durable storage, revision history and fanning edits out to everyone. With a CRDT the server is a convenience. With OT it's what makes the ordering correct.</p>\n</details>\n<details>\n<summary>When may the collab worker acknowledge an edit to the client, and why does the order matter?</summary>\n<p>Only after the edit has been appended durably to the operation log. The acknowledgement tells the client it can forget the edit. If the worker acknowledged first and crashed before the append, or relied only on snapshots taken every 30 seconds, the edit would be gone and the client would never resend it. With the append first, a crash before the ack just means the client resends the edit to the new worker, and its client sequence number lets the worker drop a duplicate.</p>\n</details>\n<details>\n<summary>Why are cursor positions handled separately from document edits?</summary>\n<p>They have different requirements. Edits are the document: they must be ordered, never lost, and stored. Cursor positions are ephemeral: a lost update is corrected by the next one, and nobody needs a cursor's history. Sending cursor movement through the operation log would add a large number of durable writes for data nobody reads again. So cursors go through an in-memory channel (Redis pub/sub or the collab worker), are throttled on the client, and are anchored to positions in the text, not to screen coordinates.</p>\n</details>\n<details>\n<summary>Two users offline for an hour both rewrite the same paragraph. Does a CRDT give a good result?</summary>\n<p>It gives a consistent result: once both sets of edits are merged, both users see the same text. It doesn't give a meaningful one. Both rewrites are kept and woven together by position, which may read as nonsense. Convergence is about replicas agreeing, not about understanding intent. That gap is handled in the product, for example by highlighting large merged changes or turning offline edits into suggestions someone reviews.</p>\n</details>\n<h3 id=\"further-reading\">Further reading</h3>\n<ul>\n<li><a href=\"https://dl.acm.org/doi/10.1145/289444.289469\" target=\"_blank\" rel=\"noopener noreferrer\">Operational transformation in real-time group editors: issues, algorithms, and achievements (Sun and Ellis, CSCW 1998)</a>: transformation functions, the correctness conditions, and where earlier algorithms went wrong.</li>\n<li><a href=\"https://hal.inria.fr/inria-00609399/document\" target=\"_blank\" rel=\"noopener noreferrer\">Conflict-free Replicated Data Types (Shapiro, Preguiça, Baquero and Zawirski, 2011)</a>: the paper that defines state-based and operation-based CRDTs and strong eventual consistency.</li>\n<li><a href=\"https://martin.kleppmann.com/2020/07/06/crdt-hard-parts-hydra.html\" target=\"_blank\" rel=\"noopener noreferrer\">CRDTs: The Hard Parts (Martin Kleppmann, 2020)</a>: a talk on interleaving anomalies, moving items in lists, and metadata overhead.</li>\n<li><a href=\"https://www.figma.com/blog/how-figmas-multiplayer-technology-works/\" target=\"_blank\" rel=\"noopener noreferrer\">How Figma's multiplayer technology works</a>: Figma's account of a central-server design inspired by CRDTs, with one process per document.</li>\n<li><a href=\"https://github.com/yjs/yjs\" target=\"_blank\" rel=\"noopener noreferrer\">Yjs</a>: an open-source CRDT library for text and structured documents, with WebSocket and peer-to-peer providers.</li>\n<li><a href=\"https://web.archive.org/web/20100724185208/http://waveprotocol.org/whitepapers/operational-transform\" target=\"_blank\" rel=\"noopener noreferrer\">Google Wave operational transformation whitepaper (archived)</a>: the client-server OT protocol Google Wave used, including the one-operation-in-flight rule.</li>\n<li>Clarence Ellis and Simon Gibbs, \"Concurrency control in groupware systems\", SIGMOD 1989: the paper that introduced operational transformation.</li>\n</ul>","headings":[{"id":"check-yourself","title":"Check yourself","depth":3},{"id":"further-reading","title":"Further reading","depth":3}]}],"sourceFiles":[]}