cf.completefrontend
My progress
FUNDAMENTALS · MODULE 6

Operational transformation (OT)

A way to merge edits made at the same time: each incoming edit is rewritten, or transformed, to account for the edits it missed, so every copy ends with the same text. Google Docs is built on it.

Why edits need rewriting

In the lesson on collaboration basics, Asha and Ben edited the line big cat at the same moment. Asha inserted black at position 4. Ben deleted 4 characters at position 0. Each editor applied the other's instruction as it arrived, and the two copies ended up different: catblack on one screen, black cat on the other.

Nothing was lost and nobody made a mistake. Asha's "position 4" was correct for the text she saw, big cat, but by the time it reached Ben his text was cat, and position 4 pointed past the end. A position only means something together with the document it was measured on.

Operational transformation (OT) is the fix that keeps the numbers and corrects them. Before an editor applies an edit that was made without knowing about some other edit, it rewrites the edit's position to account for what it missed. Ben's editor sees that Asha's insert came after a delete of 4 characters that sat entirely before it, so it moves the insert from 4 to 0. Both copies now read black cat.

Two panels side by side, both starting from Ben's copy, which reads cat after his delete. Left, without transforming: Asha's insert of black at position 4 is applied as written, position 4 is past the end, so the text lands at the end and Ben sees catblack, which differs from Asha's black cat. Right, with transforming: the insert is moved from 4 to 0 because Ben deleted 4 characters before it, and Ben sees black cat, the same as Asha.without transformingwith transformingBen's copy after his delete012catBen's copy after his delete012catinsert 4 "black ""catblack "differs from Ashainsert 0 "black ""black cat"same as Ashadeleted 4 before it: 4 - 4 = 0
Same edit, same copy. Applied as written, insert 4 points past the end of cat. Transformed against Ben's delete, it becomes insert 0 and both screens agree.

Every edit has a context

The key idea in this whole lesson is the context of an edit: the exact version of the document the author was looking at when they made it. insert 4 "black " in the context big cat is a different instruction from the same words in the context cat. Two edits are concurrent when neither author had seen the other's edit, so each is missing the other.

Applying an edit outside its context is like looking for "the fourth house on the left" after a new house was built at the start of the street: you stop one house too early. The house you meant did not move; only the count did, and OT is the rule book for fixing the count.

Four ways out of the problem

Every collaborative product has to deal with stale positions somehow. There are four common ways, and it helps to see OT next to the others.

  • Lock it. Only one person may edit a region at a time. Fine for a spreadsheet cell or a slide, useless for two people typing in the same paragraph.
  • Last writer wins. Each client sends its whole copy and the newest one replaces the rest. Simple, and it silently throws away the other person's typing.
  • Rewrite the positions. Keep plain numeric positions, and transform each concurrent edit against the edits it missed. This is OT.
  • Stop using positions. Give every character a permanent unique id and say "insert after character a7" instead of "insert at 4". Ids never go stale, so no rewriting is needed. This is the idea behind CRDTs (conflict-free replicated data types), the topic of a later lesson.

OT is the older of the two main answers. Clarence Ellis and Simon Gibbs first described it in 1989, for a group editor called GROVE. In 1995 the Jupiter system at Xerox PARC showed how a central server makes it much simpler, and Google Wave (2009) and the rebuilt Google Docs (2010) use that server design. ShareDB, CKEditor 5, and Etherpad are built on transformation too.

Check your understanding · Exercise 1

The text is pay rent. Asha inserts the at position 4 (giving pay the rent). At the same time Ben inserts Please at position 0. Ben's editor then receives Asha's edit unchanged. What goes wrong? Select all that apply.

The mental model: operations and the diamond

OT sends operations, small objects that each describe one change, never the whole document. For plain text two kinds are enough: insert puts some text at a position, and delete removes a number of characters starting at a position. Positions count characters from 0, so in the cat sat the t of the is at 0 and the c of cat is at 4.

ts
type Insert = { type: "ins"; pos: number; text: string; site: number };
type Delete = { type: "del"; pos: number; len: number };
type Op = Insert | Delete;

function apply(doc: string, op: Op): string {
  if (op.type === "ins") return doc.slice(0, op.pos) + op.text + doc.slice(op.pos);
  return doc.slice(0, op.pos) + doc.slice(op.pos + op.len);
}

// "the cat sat" + { type: "ins", pos: 4, text: "big ", site: 2 }  ->  "the big cat sat"
// "the cat sat" + { type: "del", pos: 0, len: 4 }                 ->  "cat sat"
The whole data model for plain-text OT. site is the id of the client that typed the insert; it is used only to break ties.

An operation is small, usually a few dozen bytes, whatever the size of the document. Typing a word into a 300-page report sends no more data than typing it into a one-line note.

One function does all the work

The heart of OT is a single function, transform(a, b). It takes two operations made on the same version of the text, and returns a new version of a that does what a meant, but can run after b has already been applied. Read it as "rewrite a so it knows about b".

Back to the cat sat: Asha inserts big at 4 while Ben deletes 4 characters at 0. transform(asha, ben) returns "insert big at 0", because Ben removed 4 characters before her position. transform(ben, asha) returns Ben's delete unchanged, because Asha's insert sits after the range he deleted.

A diamond of four document versions. At the top, the start: the cat sat. The left edge is Asha's insert of big at 4, giving the big cat sat. The right edge is Ben's delete of 4 characters at 0, giving cat sat. From the left, Ben's delete transformed against Asha's insert is still delete 4 at 0. From the right, Asha's insert transformed against Ben's delete becomes insert at 0. Both paths end at the bottom with big cat sat."the cat sat""the big cat sat""cat sat""big cat sat"Asha: ins(4, "big ")Ben: del(0, 4)Ben's, transformed:del(0, 4)Asha's, transformed:ins(0, "big ")
The two paths must meet at the same text: that is TP1. Each lower edge is the other person's edit, rewritten by transform against the edit already on that side.

The diamond: two paths, one answer

Draw the document versions as dots: from the starting text, Asha's edit goes one way and Ben's goes the other. Each side then applies the transformed version of the other person's edit. The two paths meet at the bottom, and OT is only correct if they meet at the same text. This shape is called the OT diamond, and it is the one picture to keep in your head.

Written as a rule, it is: applying a then transform(b, a) gives the same text as applying b then transform(a, b). Researchers call this TP1, transformation property 1. Every transform function must satisfy it for every pair of operations, or two people will end up looking at different documents with no error anywhere.

Converged is not the same as correct

TP1 promises that both copies match, not that the result is what the users wanted. A transform function that turned every concurrent pair into "delete everything" would converge perfectly. A good transform also keeps each author's intention: Asha's big still lands in front of cat, and Ben's the is still gone. The rules in the next chapter are chosen to keep both promises.

Think first

Why not just send the whole document after each keystroke and let the newest copy win?

The transform rules, one pair at a time

With two kinds of operation there are four pairs, and each needs its own rule. In every rule a is the operation being rewritten and b is the one that already happened. The question is always the same: did b change anything before a's position, or inside a's range?

Insert against insert, and the tie

If b inserted text before a's position, a moves right by the length of that text. If b inserted after, a stays put. The hard case is a tie: both inserted at the same position. The text alone cannot say whose words should come first, and both sides must make the same choice, or one copy reads AB and the other BA.

The fix is a fixed rule that both sides can compute on their own. Each client gets a site id, a number unique to that editor session, and the insert from the lower site id goes first. Any rule works as long as every copy uses the same one; the site id is simply something both sides know.

ts
// Rewrite a so it can run after b. Both were made on the same text.
// Returns a list: usually one op, sometimes none, sometimes two.
function transform(a: Op, b: Op): Op[] {
  if (a.type === "ins") {
    if (b.type === "ins") {
      const aFirst = a.pos < b.pos || (a.pos === b.pos && a.site < b.site);
      return [aFirst ? a : { ...a, pos: a.pos + b.text.length }];
    }
    if (a.pos <= b.pos) return [a];                          // before the deleted range
    if (a.pos >= b.pos + b.len) return [{ ...a, pos: a.pos - b.len }]; // after it
    return [{ ...a, pos: b.pos }]; // inside it: the text around a is gone, land at the gap
  }
  return transformDelete(a, b); // a is a delete, below
}
The two rules for rewriting an insert. A tie between two inserts is broken by site id.

Insert against delete

If b deleted a range that ends at or before a's position, a moves left by the deleted length. If the range starts at or after a's position, nothing changes. If a was typing in the middle of text that b deleted, the surrounding characters are gone, so the insert lands where the gap now is. Asha's word survives even though the sentence around it was removed: her intention was to add text, and Ben's was to remove existing text, not hers.

A flowchart for rewriting delete a against a concurrent insert b of n characters. First question: is b.pos at or before a.pos? If yes, a moves right by n. If no, second question: is b.pos at or after the end of a's range? If yes, a is unchanged. If no, b typed inside the range, so a splits in two around b's text. Example: on abcdef, delete 4 at 1 against insert X at 3 becomes delete 2 at 1, then delete 2 at 2, leaving aXf.a: delete, b: insertb.pos <= a.pos ?yespos + nrange moves rightnob.pos >= a.end ?yesunchangedtext added afternosplit a in twokeep b's text betweenabcdef, del(1, 4)vs ins(3, "X"):del(1, 2), del(2, 2)result: "aXf"
Only the last case needs care: the delete must not remove text its author never saw, so it becomes two deletes, one on each side of the new text. That is why transform returns a list.

Delete against insert: the split

Now flip it: a deletes a range, and b inserted text. If b's insert is at or before the start of the range, the range moves right. If it is at or after the end, nothing changes.

If b typed inside the range a wants to delete, a must not delete b's new words, because a's author never saw them. So the delete splits into two: the part before the new text and the part after it. This is why transform returns a list.

Delete against delete: the overlap

Two people often delete the same thing, for example when both select a typo and press Backspace. If the ranges do not touch, a only shifts left when b's range is before it. If they overlap, the characters both wanted gone are already gone, so a shrinks by the overlap, and it vanishes (an empty list) if b deleted all of it. Deleting the overlap twice would remove characters nobody selected.

ts
function transformDelete(a: Delete, b: Op): Op[] {
  const aEnd = a.pos + a.len;
  if (b.type === "ins") {
    const n = b.text.length;
    if (b.pos <= a.pos) return [{ ...a, pos: a.pos + n }]; // text added before the range
    if (b.pos >= aEnd) return [a];                         // text added after it
    const before = b.pos - a.pos; // b typed inside a's range: keep b's text, split a
    return [{ ...a, len: before }, { ...a, pos: a.pos + n, len: a.len - before }];
  }
  const bEnd = b.pos + b.len;
  if (aEnd <= b.pos) return [a];                         // b removed text after a
  if (a.pos >= bEnd) return [{ ...a, pos: a.pos - b.len }]; // b removed text before a
  const overlap = Math.min(aEnd, bEnd) - Math.max(a.pos, b.pos);
  const len = a.len - overlap;                           // b already removed the overlap
  return len === 0 ? [] : [{ ...a, pos: Math.min(a.pos, b.pos), len }];
}
The two rules for rewriting a delete. The split pieces run one after the other, so the second piece's position already accounts for the first.
The four rules at a glance.
a, rewritten against bb is an insertb is a delete
a is an insertShift right by the inserted length if b is earlier, or at the same spot with a lower site id.Shift left by the deleted length if the range is before a; move to the gap if a was inside it.
a is a deleteShift right if b is at or before the range; split in two if b is inside it.Shrink by the overlap; shift left if b is before; vanish if b already removed it all.

Test the rules before you trust them

Off-by-one mistakes in these rules are easy to make and hard to see, because the copies only drift apart for certain pairs of edits. The reliable way to check is a property test: generate random short strings and random pairs of operations, apply both sides of the diamond, and compare. The functions above pass TP1 for 200,000 random pairs, including tens of thousands of ties, overlaps, and splits. Write that test before anything else; it takes about 20 lines.

Try:
Asha (site 2):
Ben (site 1):
Starting text "the cat sat". Asha applies ins(4, "big ") and gets "the big cat sat", then Ben's edit transformed to del(0, 4), ending with "big cat sat". Ben applies del(0, 4) and gets "cat sat", then Asha's edit transformed to ins(0, "big "), ending with "big cat sat". They match. without transforming: "big cat sat" and "cat big sat".012345678910the·cat·satins(4, "big ")del(0, 4)"the big cat sat"Asha's copy"cat sat"Ben's copydel(0, 4)ins(0, "big ")"big cat sat"Asha, final"big cat sat"Ben, finalsame textwithout transforming: "big cat sat" and "cat big sat"
Each person applies their own edit first, then the other person's edit after transform. A dot is a space. Ben is site 1, so he wins ties. The bottom line shows what applying the edits unchanged would do.

Check your understanding · Exercise 2

The text is abcdefgh. Asha deletes 4 characters at 2 (cdef). At the same time Ben deletes 3 characters at 4 (efg). What is transform(ben, asha), Ben's delete rewritten to run after Asha's? Select all that apply.

Check your understanding · Exercise 3

Two users insert at the same position at the same time: site 4 inserts X, site 9 inserts Y, both at position 3. With the lower-site-first rule, what happens? Select all that apply.

A worked example: two users, one server

Now follow one full session. Asha and Ben share the line the cat sat, with a server between them as in the collaboration basics lesson. Ben joined first, so his site id is 1; Asha's is 2. The server keeps a revision number, a counter that goes up by one for every operation it accepts, and it starts at 0.

What the two of them type

  1. Asha inserts big at 4. Her screen shows the big cat sat at once (local echo), and her editor sends the operation, stamped "made on revision 0".
  2. At the same moment Ben deletes the , 4 characters at 0. His screen shows cat sat, and his editor sends that delete, also stamped revision 0.
  3. A moment later Ben types A at 0, so his screen shows A cat sat. His delete is still waiting for the server's reply, so this insert waits in a local buffer instead of being sent: one change in flight at a time, as in the basics lesson.
A sequence diagram with Asha, the server, and Ben. Asha sends insert big at 4 based on revision 0. Ben sends delete 4 at 0 based on revision 0. The server acks Asha with revision 1 and forwards her insert to Ben. It forwards Ben's delete to Asha as revision 2 and acks Ben. Ben then sends his buffered insert of A at 0 based on revision 2. The server acks it as revision 3 and forwards it to Asha.AshaServerBenins(4, "big ") @0del(0, 4) @0ack r1ins(4, "big ") r1del(0, 4) r2ack r2ins(0, "A ") @2ack r3ins(0, "A ") r3
@0 is the revision an operation was made on; r1 is the revision the server gave it. The highlighted message is the one Ben must transform twice, against his in-flight delete and his buffered insert.

What the server does

Asha's insert arrives first. It was made on revision 0, the server's current revision, so it has missed nothing. The server applies it (text the big cat sat, revision 1), tells Asha "accepted as revision 1", and forwards the insert to Ben.

Ben's delete arrives next, also made on revision 0, but the server is now at revision 1, so the delete missed Asha's insert. The server runs transform(benDelete, ashaInsert). The delete covers positions 0 to 3 and the insert is at 4, outside the range, so the delete comes back unchanged. The server applies it (text big cat sat, revision 2) and forwards it to Asha.

What Ben's editor does with Asha's insert

Asha's insert reaches Ben while his delete is in flight and his A is in the buffer. His screen, A cat sat, already includes both of his edits, and Asha's insert knows about neither, so his editor transforms it twice, in order.

  1. Against his in-flight delete: the delete removed 4 characters before position 4, so the insert moves to 0.
  2. Against his buffered A at 0: now both inserts are at position 0, a tie. Ben's site id 1 is lower than Asha's 2, so Ben's text goes first and Asha's insert moves right by 2, to position 2.
  3. His editor applies "insert big at 2" to A cat sat and shows A big cat sat.

Each step also rewrites Ben's own pending operations against Asha's insert, so they still make sense when they reach the server. When the server confirms the delete, Ben's editor sends the buffered A stamped revision 2. The server has nothing newer, so it applies it as revision 3 and forwards it to Asha, whose big cat sat becomes A big cat sat. Three copies, one text.

Lower site id:

Two users, one serverStep 1 of 11: Start

Three columns: Asha, the server, and Ben. Each client shows its text, its revision, its in-flight operation, and its buffer; the server shows its text and history. Asha inserts big at 4 while Ben deletes the and then inserts A. The server orders them, and each client transforms incoming operations against its pending ones, until all three read the same text at revision 3.Asha (site 2)"the cat sat"revision 0in flightemptybufferemptyBen (site 1)"the cat sat"revision 0in flightemptybufferemptyServer"the cat sat"revision 0history
All three copies read the cat sat at revision 0. Asha is site 2 and Ben is site 1.

Start: All three copies read the cat sat at revision 0. Asha is site 2 and Ben is site 1.

Think first

Ben's editor held A in a buffer instead of sending it right away. What would go wrong if it sent it immediately, stamped revision 0?

The server decides the order

The worked example leaned on the server for one thing above all: it puts every operation into a single numbered order. This is the Jupiter design that made OT practical. Each client talks only to the server, never to other clients, and the server's order is the truth.

The server's loop

The server keeps the current text, the current revision, and a history: the list of accepted operations, where entry n is the operation that turned revision n into revision n + 1. Every incoming operation carries the revision it was made on, called its base revision.

ts
// One document on the server. Ops are lists (a transform can split one op into two).
const history: Op[][] = []; // history[n] turned revision n into n + 1
let doc = "the cat sat";

function receive(ops: Op[], baseRev: number, from: Client) {
  if (baseRev > history.length) throw new Error("client is ahead of the server");
  for (const done of history.slice(baseRev)) {
    ops = transformLists(ops, done)[0]; // catch up on everything it missed, in order
  }
  doc = ops.reduce(apply, doc);
  history.push(ops);
  const rev = history.length;
  from.send({ type: "ack", rev });
  for (const other of clientsExcept(from)) other.send({ type: "op", ops, rev });
}
The whole server algorithm. transformLists is transform lifted to lists of operations: it transforms each piece in turn and returns both rewritten sides.

Notice what the server never does: it never asks a client to retry, never waits, and never rolls anything back. An operation that arrives is transformed forward and accepted. A plain-text transform is only a handful of comparisons, so the cost per operation is small.

The server's history as a list of five entries: 0 insert big at 4, 1 delete 4 at 0, 2 insert A at 0, 3 insert exclamation mark at 13, 4 delete 4 at 2. An incoming insert of fat at 6, made on revision 3, has not seen entries 3 and 4. Transformed against entry 3 it stays at 6; against entry 4, which deleted the 4 characters before it, it moves to 2. It is stored as entry 5.history on the server0: ins(4, "big ")1: del(0, 4)2: ins(0, "A ")3: ins(13, "!")4: del(2, 4)not seenby the clientins(6, "fat ")made on revision 3vs 3, then 4ins(2, "fat ")stored as entry 5
The base revision says exactly which entries the client missed. Entries 0 to 2 were already in the client's text; entries 3 and 4 were not, so the insert is transformed against them in order and becomes ins(2, "fat ").

The client's three states

The client side is a small state machine. It needs only three pieces of data: the last revision it knows, the operation in flight (sent, not yet confirmed), and the buffer (typed since, not yet sent). Which of those are empty gives three states.

  • Synchronized: nothing in flight. A local edit is sent at once and the client moves to awaiting confirm.
  • Awaiting confirm: one operation in flight. A new local edit goes into the buffer, and the client moves to awaiting with buffer. An ack moves it back to synchronized.
  • Awaiting with buffer: one in flight, more in the buffer. New local edits are added to the buffer. An ack sends the buffer as the new in-flight operation and moves back to awaiting confirm.
A state machine with three client states. Synchronized: nothing pending. A local edit is sent and moves it to Awaiting confirm, with one operation in flight. An ack moves it back to Synchronized. A local edit in Awaiting confirm goes to the buffer and moves it to Awaiting with buffer. An ack there sends the buffer and moves back to Awaiting confirm. In every state, an operation from the server is transformed against the in-flight operation, then the buffer, then applied, and the state does not change.Synchronizednothing pendingAwaiting confirmone in flightAwaiting with bufferin flight + bufferlocal edit: sendacklocal edit: buffer itack: send the bufferServer op, in any state: transform against in flight,then the buffer, apply it, revision + 1. State stays.More local edits while buffering join the buffer.
Only local edits and acks change the state. Server operations are handled the same way in all three states: transform, apply, add one to the revision.

In every state, an operation from the server has to be transformed before it is applied, because the client's screen already shows its own unconfirmed edits. It is transformed against the in-flight operation first, then against the buffer, which is the order those edits happened on the screen. At the same time the in-flight operation and the buffer are rewritten against the incoming one, so they stay valid for the server.

ts
function onServerOp(incoming: Op[]) {
  if (inFlight) [inFlight, incoming] = transformLists(inFlight, incoming);
  if (buffer) [buffer, incoming] = transformLists(buffer, incoming);
  editor.apply(incoming); // now it fits the text on screen
  rev += 1;
}

function onAck() {
  rev += 1;
  inFlight = buffer;      // may be null
  buffer = null;
  if (inFlight) socket.send({ ops: inFlight, baseRev: rev });
}
The client's two server handlers. transformLists(x, y) returns [x rewritten after y, y rewritten after x].

Why one change in flight keeps it simple

Because a client sends only after its previous operation is confirmed, every operation it sends is based on a revision the server has, and the base revision says exactly what the client has seen. Each transform happens between just two parties, the client and the server, along one agreed history. The next chapter shows why that matters so much.

We tested this client and server with three simulated clients, random edits, and random message delays. In 3,000 sessions of 60 actions each, every copy matched the server at the end.

Check your understanding · Exercise 4

The server is at revision 41. An operation arrives with base revision 38. What does the server do? Select all that apply.

TP2, and why the server makes it go away

TP1 is about two concurrent operations. With three or more people editing at once there is a second, much harder property, and it is the reason OT has a reputation for being tricky. One small example shows the problem, and how a central server avoids it.

Three editors, no server

Imagine an editor with no server, where every client sends its operations straight to every other client. The text is ab. Three people edit at the same moment, none having seen the others' edits.

  • Asha (site 1) inserts x at 1, between a and b.
  • Ben (site 2) deletes a, 1 character at 0.
  • Cara (site 3) inserts z at 0, before a.

Everyone agrees on the intended result: z was typed before a, x after it, and a is gone, so the text should read zxb. Now suppose one peer received Asha's insert before Ben's delete, and another received them the other way round. When Cara's insert arrives, each peer transforms it against the other two along its own path.

  • Path 1, Asha then Ben. Cara's z at 0 is before Asha's x at 1: unchanged. Ben's delete, rewritten against Asha, still deletes position 0, and z at 0 is not inside it: unchanged. Result: zxb. Correct.
  • Path 2, Ben then Asha. Against Ben's delete, z stays at 0. Asha's x, rewritten against Ben's delete, moves from 1 to 0. Now z and x are both at 0: a tie, and site 1 beats site 3, so x goes first. Result: xzb. Wrong, and different from the other peer.
Order:
The text ab. Asha, site 1, inserts x at 1. Ben deletes 1 character at 0. Cara, site 3, inserts z at 0. Order: Peer 1: Asha, then Ben. Ben's delete, rewritten after Asha's insert, is del(0, 1). Cara's insert after Asha's insert is ins(0, "z"), and after both it is ins(0, "z"). The text before Cara's insert is xb and the result is zxb, which is the intended zxb.01abAsha (1): ins(1, "x")Ben: del(0, 1)Cara (3): ins(0, "z")1. Ben's delete, after Asha's insert:del(0, 1)2. Cara's insert, after Asha's insert:ins(0, "z")3. Cara's insert, after Ben's delete too:ins(0, "z")"zxb"intended: z, then x
Every single transform follows the rules, yet the two peers disagree. In the Ben-first order, Asha's x slides to position 0, ties with Cara's z, and the tie-break picks the wrong order.

Each individual transform followed the rules and passed TP1. The failure comes from combining them. When Ben's delete removed a, it also erased the only fact that told the two inserts apart: one was left of a, the other right of it. Path 2 turned a clear order into a tie, and the tie-break guessed wrong.

What TP2 asks for

TP2, transformation property 2, says: transforming an operation against two concurrent operations must give the same result whichever order you take them in. Not just the same text in the end, but the same rewritten operation, because that operation may be transformed again later. Our functions break TP2, and so do most simple ones.

Fixing it is hard. One known approach keeps deleted characters as invisible tombstones, so positions never collapse and the left-of-a fact survives. In 2003, Imine and colleagues checked several published transform functions with an automated theorem prover and found TP2 counterexamples in them. Peer-to-peer OT is still largely research work.

How the server sidesteps TP2

With a central server, there is only one path. The server picks one order for all operations, and every operation is transformed against the history in that order and no other. A client only ever transforms between two parties: its own pending edits and the server's stream. No operation is ever transformed along two different paths, so TP2 is never needed: TP1 alone is enough.

In the example, the server might accept Asha, then Ben, then Cara. Cara's insert is transformed against Asha's, then against Ben's rewritten delete: path 1, every time, on every client. That is the Jupiter insight, and it is why Google Wave and Google Docs route every edit through a server.

QuestionPeer-to-peer OTOT with a central server
Properties neededTP1 and TP2TP1 only
Who orders operationsEach peer, using version vectorsThe server, with one revision counter
Works with no serverYesNo: edits queue until the server is reachable
Extra stateTombstones or full history per peerHistory on the server since the oldest client's base
Used byMostly research systemsGoogle Docs, ShareDB, CKEditor 5, Etherpad

Check your understanding · Exercise 5

Which statements about TP2 are true? Select all that apply.

Where the complexity really lives

Plain text with insert and delete is the friendly version of OT: four rules, about 30 lines, and a property test. Real editors are not plain text. A Google Docs document has bold and italic ranges, headings, lists, tables, images, comments, and suggestions. Every one of those is a new kind of operation, and that is where OT gets expensive.

Every new operation type multiplies the rules

Each kind of operation must be transformable against every kind, including itself. Two kinds need 4 rules. Add format (make a range bold), split (press Enter inside a paragraph), and merge (press Backspace at the start of one), and five kinds need 25 rules. Each rule has its own edge cases, like overlaps and ties, and each must pass TP1 for every input.

A 5 by 5 grid of operation kinds: insert, delete, format, split, and merge, as rows for the operation being rewritten and as columns for the one already applied. The four cells where both are insert or delete are marked text: the four rules of plain-text OT. The other 21 cells are marked rich: rules a rich-text editor also needs.rows: the op being rewritten; columns: the one already appliedinsertdeleteformatsplitmergeinsertdeleteformatsplitmergetexttextrichrichrichtexttextrichrichrichrichrichrichrichrichrichrichrichrichrichrichrichrichrichrich4 rules for plain text, 21 more for rich text
Plain text needs the 4 rules in the corner. Adding three kinds of operation for rich text adds 21 more, each with its own ties and overlaps to get right.

Some pairs have no obvious right answer. Asha makes a sentence bold while Ben deletes half of it: bold the half that is left. Asha splits a paragraph while Ben merges it with the one above: probably both, in some order, but which text ends up in which paragraph? Ben moves a list item while Asha edits it: the edit should follow the item.

The CKEditor 5 team wrote that applying OT to their tree-shaped documents needed new operations such as rename, split, and merge. Their first implementation took over a year, with several significant reworks over the next two years.

Batching: operations that compose

Sending one operation per keystroke works, but real systems batch. While an operation is in flight, the buffer may collect 20 keystrokes, and editors compose them, merging them into one operation. Many production formats express an operation as a walk over the whole document: retain 12, insert "big ", retain 7, delete 3. That shape composes and transforms neatly, and it is used by ot.js, ShareDB's text types, and Quill's Delta format, which ships compose and transform methods.

json
{ "ops": [
  { "retain": 4 },
  { "insert": "big " },
  { "retain": 3, "attributes": { "bold": true } },
  { "delete": 4 }
] }
A Quill Delta: keep 4 characters, insert big , make the next 3 bold, delete 4. One operation can mix many edits.

Cursors and undo are transforms too

A cursor is a position, so it goes stale exactly like an insert does. When Asha's insert lands before Ben's cursor, Ben's own cursor must move right, or his next keystroke appears in the wrong place. Remote cursors and selections shown for presence must be transformed the same way. Libraries expose this as a small helper, such as transformPosition in Quill's Delta.

Undo is harder. In a shared document, Ctrl+Z should undo your last edit, not whatever happened last in the document. So the editor keeps the inverse of each of your operations (the inverse of a delete is an insert of the deleted text, which means deletes must remember what they removed) and transforms that inverse against every operation that happened since. Undo of an edit that someone else has since edited around is a classic source of bugs.

Check your understanding · Exercise 6

Your team adds a format operation (bold, italic) to an OT text editor that already has insert and delete. Which statements are true? Select all that apply.

What goes wrong in production

A correct transform function is necessary but not enough. Many OT bugs in real products come from the code around it: how positions are counted, how reconnects are handled, and how the server scales. Here are the common ones, how you notice them, and how you fix them.

Two zones. In the browser: the editor view, such as ProseMirror or Quill, on top of the OT client, which holds the in-flight operation and the buffer, and below it cursors and presence, whose positions are transformed too. A WebSocket connects the OT client to the server zone, carrying operations with base revisions one way and acks and operations the other. On the server: a permission check per operation, the document server, one writer per document, and the operation log plus snapshots in storage.BrowserServerEditor viewProseMirror, QuillOT clientin flight + bufferCursorspositions transformedPermissionschecked per opDocument serverone writer per docOp log+ snapshotsopsacks
Many production bugs live at the edges of this picture: the units positions are counted in, resends after a reconnect, and the history window on the server.

Counting characters differently

A position is a count of something, and the client and server must count the same thing. JavaScript strings count UTF-16 code units, so an emoji like a thumbs-up is 2 units long. A server in Python counts code points (1 for that emoji), and one in Go or Rust often counts UTF-8 bytes (4). The moment someone types an emoji, every later position is off by one or more, and text gets inserted in the middle of words or of the emoji itself.

You notice it as corruption that only appears in documents with emoji or non-Latin scripts. The fix is to pick one unit, write it in the protocol, and convert at the edges. Many teams pick UTF-16 code units because the editor runs in the browser.

Reconnects that apply an edit twice

A client sends an operation, the connection drops before the ack arrives, and the client reconnects and sends it again. If the first copy did reach the server, the text now has the insert twice. The fix: every operation carries the client's id and a per-client sequence number, so the server can spot a resend, acknowledge it again, and not apply it twice. ShareDB, for example, compares a resent operation's src and seq fields with the operations committed since its base version, and treats a match as already applied.

Clients that fall far behind

The server must keep history back to the oldest base revision any client might send. A laptop that sleeps for a day and wakes up with edits at revision 1,200, while the server is at 9,000, needs 7,800 transforms, and the server must still have all of them. Many servers keep a bounded window, for example the last few thousand operations, plus periodic snapshots. A client whose base revision is older than the window gets the latest snapshot and must rebase or show its unsent edits to the user.

Silent divergence

When a transform rule is wrong, nothing throws. Two people simply see different text and keep editing, and each edit makes the damage worse. Detect it: the server sends a short hash of the document every so often (say every 50 revisions), and the client compares it with a hash of its own copy at the same revision. On a mismatch the client reloads the snapshot and logs the operations that led there, so the bug can be reproduced with the property test.

One writer per document

The server's loop must handle one operation at a time per document, because each one is transformed against the one before it. Two server processes accepting operations for the same document would each build their own history. So production systems route every document to exactly one process, and scale out by spreading documents across processes, not by splitting one document.

A single writer per document is likely one reason products cap how many people can edit one file at once. Google's help pages say a Docs file can be edited on up to 100 open tabs or devices at a time; beyond that, only the owner and some editors can keep editing.

Check your understanding · Exercise 7

Users report that in some documents, text appears one character to the left of where they typed. It only happens after someone pastes Chinese text with emoji. What is the most likely cause? Select all that apply.

In real products, and when to pick something else

Several widely used collaborative editors run on OT today, and knowing which ones, and why, is useful in an interview.

Who uses OT

  • Google Docs. Since 2010, every edit boils down to three kinds of change (insert text, delete text, apply a style to a range) sent to Google's servers, which order them and transform concurrent ones. Each document is saved as a revision log of those changes.
  • ShareDB. An open-source Node.js server and client for OT on JSON documents and text. It grew out of ShareJS, written by Joseph Gentle, a former Google Wave engineer. It handles revisions, history, deduplication, and presence, so you only supply the document type.
  • CKEditor 5. A rich-text editor whose real-time collaboration uses OT on a tree of elements, with transforms for operations like split, merge, and move.
  • Etherpad. An open-source editor whose changesets (called Easysync) are transformed against each other on a central server.

Plenty of products chose differently. Figma's multiplayer uses a central server that resolves each property of each object with last-writer-wins, because design files are trees of objects with properties, not flowing text. Newer editors and local-first apps often pick CRDTs, through libraries like Yjs and Automerge, because they merge without a server.

Decision How should a new collaborative product merge concurrent edits?

OT with a central server

Operations with numeric positions

The server orders them and transforms each against what it missed

Clients keep one change in flight and transform incoming ops

Pros

  • Small operations, small documents: no per-character ids
  • Only TP1 needed, so the transforms are easier to get right and test
  • Established libraries such as ShareDB and Quill Delta

Cons

  • Needs the server for every merge; offline edits wait
  • Every new operation type adds transform rules
  • One process per document limits how many editors a file can have

CRDT

Every character or element gets a unique, permanent id

Operations refer to ids, so they never need rewriting

Any two copies merge in any order

Pros

  • Works peer-to-peer and offline
  • No central ordering, so servers can be simple relays or storage
  • Libraries like Yjs and Automerge handle text, lists, and maps

Cons

  • Ids and deleted-item records add memory and bytes
  • Harder to enforce server-side permissions per edit
  • Intention on complex structures still needs careful design

Server-authoritative last-writer-wins per field

Each object property is a separate value

The server keeps the latest write for each property

Clients show their own change until the server confirms

Pros

  • Very simple to build and reason about
  • Fits canvases, forms, boards, and settings

Cons

  • Useless for two people typing in the same text field
  • The losing write is simply gone

What we pick: For rich text with a reliable server and permissions to enforce, OT with a central server is a proven choice, and it is what Google Docs uses. For offline-first or peer-to-peer apps, pick a CRDT. For canvases and forms where people rarely edit the same field, last-writer-wins per field is enough. The CRDTs and conflict resolution lessons go deeper on the last two.

Performance, security, and accessibility

Performance. The cost per keystroke is small. The cost that grows is catching up: a client that missed m operations with n of its own pending needs about m times n transforms. Batching keystrokes into one composed operation, for example every 50 to 100 ms, keeps both numbers small.

Security. The server must treat every operation as untrusted input. Check that positions and lengths fit the document, cap operation size, and check permissions per operation: a user with comment access must not be able to send an insert. The server always does its own transforming; it never accepts a client's claim about what the result should be.

Accessibility. Remote edits move text under a screen reader user's reading position. Transform the local caret and selection so they stay on the same words, and never move focus because of someone else's edit. Announce remote activity sparingly through a polite live region, such as "Ben is editing paragraph 3", rather than reading out every keystroke.

Using OT in a system design interview

OT comes up in any question about a collaborative editor: design Google Docs, a shared notes app, a code pairing tool, or comments on a live document. You will rarely be asked to write transform functions on a whiteboard. You will be asked how two people's edits merge, and the interviewer wants to hear that you know positions go stale, how OT fixes them, and what a central server buys you.

What to draw

  1. The editor on each client with its local copy, an in-flight slot, and a buffer.
  2. A WebSocket to a document server that owns one document at a time, with a revision counter and an operation log.
  3. Storage: the operation log plus periodic snapshots, which also gives you version history.
  4. One concurrent example on the side, like insert at 4 against delete at 0, showing the transformed position.

What to say, by level

LevelWhat a strong answer covers
JuniorEdits are sent as small operations, not whole documents. Concurrent edits need their positions adjusted, and that is what OT does. A server keeps everyone in the same order.
MidThe client states (in flight and buffer), revision numbers, transforming incoming operations against pending ones, tie-breaking by site id, local echo, and reconnect with deduplication.
SeniorTP1 against TP2 and why the server removes TP2, one process per document and what that means for scale and the editor cap, history windows and snapshots, Unicode position units, and when a CRDT or last-writer-wins is the better choice.

Traps to avoid

Spend about two minutes on OT unless the interviewer pulls you deeper. Draw the diamond for one concurrent pair, name the server's role, and move on to storage, presence, and offline behavior. If they ask for more, go to TP2, the history window, and the comparison with CRDTs.

  • Saying "we use WebSockets" as if the transport solves merging. It only delivers operations; something still has to fix their positions.
  • Proposing last-writer-wins for document text. It loses typing the moment two people edit the same paragraph.
  • Claiming OT works peer-to-peer just as easily. It needs TP2 there, which is the hard part.
  • Forgetting cursors. Remote and local cursors are positions too and must be transformed.

Check your understanding · Exercise 8

An interviewer asks: "Your Google Docs design has one server process per document. What happens when that process crashes?" Which answers are sound? Select all that apply.

Summary

  • A position only means something together with the document version it was measured on. Concurrent edits make each other's positions stale.
  • OT rewrites each operation against the concurrent operations it missed with one function, transform(a, b), and the result must satisfy TP1: both paths of the diamond give the same text.
  • Plain text needs four rules: insert and delete against each other. Ties break by site id; a delete splits around text typed inside it and shrinks by any overlap with another delete.
  • A central server gives every operation a revision number and one order. Clients keep one operation in flight, buffer the rest, and transform incoming operations against both.
  • With one order there is only one transform path, so TP2 is never needed. Peer-to-peer OT needs TP2, which is notoriously hard.
  • The cost of OT grows with the number of operation types: rich text, trees, cursors, and undo each add rules that must be tested.
  • In production, agree on position units, deduplicate resent operations, keep a bounded history plus snapshots, check document hashes, and run one writer per document.

In the interview

Questions interviewers ask about this. Answer each out loud first, then compare.

Question 1

Two users edit the same paragraph at the same time. How does OT make sure both end up with the same text?

Question 2

Walk me through what the server does when it receives an operation.

Question 3

Why does the client only keep one operation in flight?

Question 4

What is TP2 and why doesn't Google Docs need it?

Question 5

When would you choose a CRDT over OT?

Practice it

These design problems lean on operational transformation (ot).

References

Read next