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

storm hits.

Insert "night " at 4

stornight m hits.

Kofi's

The night storm hits.

Delete 4 characters at 0

night storm hits.

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 tombstones

Because 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:

  1. Kofi types "night "

    The insertion goes on Kofi's undo stack.

  2. Ines deletes "The "

    A remote change: it goes on Ines's stack, not Kofi's.

  3. Kofi presses undo

    The editor removes exactly the characters Kofi inserted, wherever they are now: storm hits.

  4. 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).

Editors' browsersWebSocketsRouterby document idDocument ownerin memory; orders editsChange logsmall, every ~0.5 sSnapshotswhole file, ~60 sopen the playthe one ownerbatched changesfull checkpoint

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

1. Ines deletes 4 characters at the start of a line while Kofi inserts at position 4. Under OT, where does Kofi's insertion land on Ines's copy?
2. Why does a sequence CRDT keep deleted characters as hidden tombstones?
3. Two designers set the same shape's color at the same time in a tool that uses last write wins per property. What happens?
4. Kofi presses undo right after Ines made an edit. What should be undone?
5. A document server saves a full snapshot every 60 seconds. What does adding a change log written about every 0.5 seconds buy?

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.