System designDeep dive13 min
Collaborative editing
When several people type in the same document at once, each sees their own keystrokes instantly and everyone's copy must end up identical. Two families of technique make that work: operational transformation, which shifts each change past the ones it missed, and sequence CRDTs, which give every character an identity. Around them sit presence, per-person undo, and one server process that owns each document and saves it as a log plus snapshots.
1 · The problem
Two people typing in one sentence break any sync that replays positions
A community theater writes its new play in a browser-based editor. Ines, the director, and Kofi, a writer, both have the scene description The storm hits. open. At the same moment, Ines deletes the first 4 characters ("The ") and Kofi types "night " at position 4, just before "storm". Lena, the third writer, is revising Act 3 on a train with no signal.
Each editor applies its own change at once, so typing never waits for the network, and then sends the change to the others. If each copy simply replays the other person's change at the same position, the copies split:
Copy | After its own change | Then applies the other's | Result |
|---|---|---|---|
Ines's |
| Insert "night " at 4 |
|
Kofi's |
| Delete 4 characters at 0 |
|
The two copies now disagree, and nothing will bring them back together. The goal of collaborative editing is that all copies converge: once everyone has received every change, everyone sees the same document, and that document keeps what each person meant to do.
Two older fixes fall short. Comparing whole versions and guessing a merge can't tell what each person intended: if one person bolds "brown fox" while another replaces "fox" with "dog", several merges look equally valid. Locking a paragraph to one editor blocks the collaboration the product exists for, and two people can still start typing before either sees the other's lock. The working answers are operational transformation and sequence CRDTs.
2 · Transformation
Operational transformation shifts each change past the changes it missed
Operational transformation (OT) rewrites an incoming change so that it applies to the document as the receiver has it. Ines's copy lost 4 characters before Kofi's insertion point, so Kofi's "insert at 4" becomes "insert at 0" there, and both copies read night storm hits. Google Docs' editor works this way: every edit is an insert, a delete or a style change, and the document is stored as the log of those changes.
A transform is only half the system; something must also decide the order. In Google Docs a central server plays that role, numbering every change it accepts as the next revision:
Kofi's change was made on revision 7, but Ines's reached the server first as revision 8. The server shifts Kofi's change past revision 8 before storing it as revision 9, so every copy applies the same changes in the same order.
The client side has its own rules. Each Google Docs client tracks the last revision it has seen, the local changes not yet sent, and the changes sent but not yet acknowledged, and it never has more than one change in flight: new typing waits in the pending list until the acknowledgment arrives. Incoming changes are transformed against the pending ones, so the local view stays correct while the user keeps typing.
OT has a reputation for being hard, for a concrete reason. The transform must handle every pair of change types correctly, and with structured documents (tables, lists, nested blocks) a transform that always converges becomes very hard to define. A long list of published OT algorithms were later shown to be incorrect. Without a central server, OT generally also needs to keep records of deleted text, which brings it close to the next technique.
3 · Sequence CRDTs
Sequence CRDTs give every character an identity, so changes apply in any order
A sequence CRDT stops describing edits by position (CRDTs: data types that always merge covers the family). Every inserted character gets a permanent id made of the inserting client's id and a counter, and an insertion names the characters it goes between. Kofi's edit then means the same thing on every copy, however much the text around it has shifted:
The two edits as CRDT operations
Ines typed "The storm hits." as ids (ines,0) … (ines,14)
Kofi's insert
id: (kofi,31) … (kofi,36) # client id, counter
after: (ines,3) # the space after "The"
before: (ines,4) # the "s" of "storm"
text: "night "
Ines's delete
hide (ines,0) … (ines,3) # kept as tombstonesBecause the space Kofi anchored to still exists as a hidden character, Kofi's insertion lands in the right place on Ines's copy even though Ines deleted that space. Hidden deleted characters are called tombstones. When two people insert at exactly the same spot, every copy must order the insertions the same way; sorting by a logical timestamp and then by client id is one rule that does it (Time, clocks & ordering).
The result is that changes need no transform and no central order: they can arrive in any order, or twice, and copies still converge. Yjs, a widely used CRDT library, states that its document updates are commutative, associative and idempotent. The trade-offs against OT:
Aspect | OT with a central server | Sequence CRDT |
|---|---|---|
A change refers to | Positions in the text | Ids of characters |
Who decides the order | The server, by revision number | The data type itself |
Works peer-to-peer | Hard; needs extra history | Yes, over any delivery path |
Extra data | Little beyond the text | Ids per character, plus tombstones |
The extra data is smaller than it sounds. Yjs stores a run of characters typed in order by one client as a single item, and records deletions as ranges. On a real editing trace with about 182,000 inserted and 77,000 deleted characters, the record of deletions took 4.5 KB. With garbage collection on, deleted content is discarded and only its length is kept.
4 · Beyond text
Merge each kind of data in the simplest way that keeps intent
Most of a document isn't running text. The play editor also has scenes in an order, lighting cues with times, and formatting. Figma, a design tool, models its documents as objects with properties, and its server keeps the last value any client sent for each property of each object. Two people changing different properties, or the same property on different objects, never conflict at all.
Data | Merge rule | In the play editor |
|---|---|---|
Running text | OT or a sequence CRDT | Ines and Kofi typing in one line |
A single value | Last write wins, in the server's order | Two people set cue 14 to 7 s and 9 s |
Order of items | Fractional positions between neighbors | Moving scene 4 between scenes 1 and 2 |
Formatting | Spans that grow with text typed inside | Bold applied while someone types within it |
A tree | Parent stored on the child; cycles rejected | Moving a scene into another act |
Last write wins is cheap but blunt. Figma's own example: if a text value "B" is changed to "AB" by one person and to "BC" by another, the result is one of them and never "ABC". That is fine for a cue time and wrong for a speech, which is why the script text needs a real text merge.
Fractional indexing orders items without renumbering: each item has a position between 0 and 1, and an item placed between two others gets a value between theirs, such as (0.5 + 0.75) ÷ 2 = 0.625. Formatting is a range that must stretch: Google Docs transforms "bold 10 to 20" against "insert ABC at 15" into "bold 10 to 23", so text typed inside a bold span becomes bold.
5 · Presence & undo
Cursors and undo belong to each person, not to the document
Collaborators need to see who is where. Presence, meaning each person's cursor, selection, name and color, is shared live but not saved with the document. Yjs keeps it in a separate small structure: each user sets their own state, and when a user goes offline their state is removed and everyone else is told (Presence & push notifications).
A cursor can't be stored as a number. Kofi's cursor at position 10 points at the wrong letter once Ines deletes 4 characters before it. Positions are anchored to characters instead: Yjs's relative positions attach to the character after the cursor and stay with it as text changes around it, and all copies resolve them to the same place once synced. With plain offsets that guarantee isn't possible, even in an OT system whose documents converge. Comments on a range of text need the same anchoring.
Undo is per person. When Kofi presses undo, Kofi's own typing should disappear, not Ines's deletion, so each participant needs their own undo history. Yjs's undo manager tracks only local changes by default, and undoing an old change means first transforming it past everything that happened since:
- Kofi types "night "
The insertion goes on Kofi's undo stack.
- Ines deletes "The "
A remote change: it goes on Ines's stack, not Kofi's.
- Kofi presses undo
The editor removes exactly the characters Kofi inserted, wherever they are now:
storm hits. - Ines's deletion stays
Nothing Ines did is reverted; Ines's own undo history still holds the deletion.
Redo needs the same care. A useful test: if you undo several times, copy something, and redo back to the present, the document should be exactly as it was. A naive redo that "puts back what I did" can overwrite what others did in the meantime.
6 · The server
One owner per document holds it in memory and saves a log plus snapshots
Most collaborative products route each document through a central server that orchestrates its changes. Figma runs a separate process for each open document, holds the document in memory, and connects everyone editing it to that process. If two processes each held the same document, people connected to each would see different versions: a split brain (Failure detection & split brain).
Every editor of the play reaches the same in-memory owner. It appends small batches of changes to a log often and writes a full snapshot rarely; recovery loads the newest snapshot and replays the log after it.
How the owner saves decides how much work a crash loses. Figma first wrote the whole file every 30 to 60 seconds, so a crash could lose up to 60 seconds of edits, and every deploy closed all files at once and caused a burst of writes. Adding a log of incremental changes, each with a sequence number, changed the numbers:
Saving | Written | Lost if the owner crashes |
|---|---|---|
Snapshots only | Whole file every 30 to 60 s | Up to 60 s of edits |
Snapshots plus a change log | Changes about every 0.5 s | Under 1 s (Figma's goal) |
Recovery reads the latest snapshot, then every log entry with a higher sequence number. Clients send updates up to 30 times a second, so the owner batches them before writing. Ownership has to be enforced, not assumed: Figma records which process owns each file and makes every log write conditional on that record, so a second process can't append a conflicting history (Distributed locks).
7 · Offline
Offline edits merge on reconnect, but a clean merge still needs a human look
Lena edits Act 3 for two hours on the train. When the connection returns, the editor must bring the copies together (Offline-first & optimistic UI). Figma's client downloads a fresh copy of the document, reapplies the offline edits on top of it, and resumes syncing. A CRDT client can instead send a state vector, how many changes it has from each client, and receive only the changes it lacks, while sending its own in return.
A state vector is a count per client. Lena's laptop says how far it has each person's changes, receives only the 120 it lacks, and sends its 212 offline changes, which the owner passes on to everyone else.
The merge always succeeds, but success isn't the same as sense. If Kofi rewrote the same speech while Lena was away, each deletion hides only the text its author could see, and both new versions survive side by side in one paragraph. Nothing is lost, which is the point, but the play now has two speeches. A useful habit is to flag long offline merges that touched the same paragraph so a person can review them.
Decision | Choice for the play editor |
|---|---|
Text merge | OT with a central server, or a sequence CRDT |
Properties and order | Last write wins per property; fractional positions |
Local edits | Applied at once; one batch in flight to the server |
Presence | Live, anchored to characters, never saved |
Undo | Per person, transformed past later changes |
Server | One owner per document, enforced by a lock |
Storage | Change log every fraction of a second, snapshots every minute |
Offline | Merge on reconnect; flag overlapping rewrites |
Check yourself
5 questions
Takeaways
Remember this
- Apply local edits at once and make all copies converge; replaying edits by position alone makes them diverge.
- Operational transformation shifts each change past the ones it missed, with a central server deciding the order.
- Sequence CRDTs give every character an id and keep tombstones, so changes apply in any order without a server.
- Use the simplest merge per kind of data: text merges for text, last write wins for single values, fractional positions for order.
- Give each document one enforced owner that saves a frequent change log plus periodic snapshots, and keep presence and undo per person.