Two people put their cursor in the same sentence and start typing. Both machines apply their own keystroke immediately, because an editor that waits for a server feels broken. The edits cross on the wire. A few milliseconds later both screens have to show the same paragraph. Not a merge conflict, not a dialog asking who wins. The same paragraph, and neither person should notice anything happened.
That is the entire problem. It sounds small. It is not.
I built a collaborative scheduling engine at Scriptyl on top of these ideas. Film crews of a hundred and fifty people editing the same production plan, often on bad hotel wifi, often with no connection at all for hours. The thing that surprised me most is how little of the difficulty lives in the algorithm. But the algorithm is genuinely beautiful, so let me start there.
Why the obvious approach fails
Represent the document as a string. Represent an edit as insert("x", at: 5).
Alice inserts "A" at position 2. At the same moment Bob inserts "B" at
position 5. Alice's edit arrives at Bob's machine and gets applied at index 2, but
Bob's document has already shifted, and his own insert is now in the wrong place.
Positions only mean something relative to one particular version of the document. The moment two people edit at the same time, there is no one particular version.
There are two families of solution to this.
Operational Transformation keeps positional operations and transforms them
against each other when they arrive. When Alice's insert at 2 reaches Bob, it is
transformed against Bob's insert at 5 into an operation that is correct for
Bob's current state. It works. Writing the transformation functions so that every
pair of operation types converges under every arrival order is famously hard.
Several published OT algorithms were later shown to be wrong, which tells you
something.
CRDTs take the other route. They change the data structure instead of patching the operations.
The trick is to stop using positions
The core idea of a sequence CRDT is that position 5 is a terrible name for a character, because it keeps changing. So give every character a name that never changes, and make those names sortable.
The operation stops being "insert at index 2" and becomes "insert this character with identifier k". The document is simply every character sorted by identifier. Order becomes a property of the characters themselves rather than of when anything arrived.
The one requirement is that between any two identifiers you can always create a new one. That is a dense total order. The rational numbers have this property and the integers do not, which is exactly why integer indices fail.
The easiest version to picture is fractional. Characters sit at 0.2 and 0.6, and you insert between them at 0.4. Real implementations use a list of digits with a site identifier attached, so two people inserting into the same gap produce different identifiers and ties break the same way on every machine.
Both machines end up with H!?i. Not because they agreed on anything, but
because sorting the same set of identifiers can only produce one answer. No
server decided. No operation was transformed.
That is the beautiful part, and it is worth sitting with for a second. Convergence stops being a protocol you have to get right and becomes a property of a sort.
Deletion works the same way. You cannot actually remove the character, because a concurrent insert somewhere else may be using it as an anchor. So you mark it as deleted and leave it in place. That is a tombstone, and it brings us to the first uncomfortable part.
What it costs
Every character carries an identifier. Every deleted character stays. A document that has been edited heavily for a year can carry several times more metadata than visible text, and opening it means loading all of it.
Modern implementations fight this hard and mostly win. Yjs and Automerge both use run length encoding, so a burst of sequential typing is stored as one block with a starting identifier and a length instead of forty separate entries. That matches how people actually type. Yjs's YATA algorithm, Automerge's columnar encoding and the more recent Fugue work have moved the practical overhead from obviously unacceptable to usually fine.
Garbage collecting tombstones is harder. To remove one safely you need to know every replica has seen the deletion, and in a system where somebody can be offline for a month, you do not know that. Most systems compromise. They collect after a window and accept that a client returning later has to resync from scratch.
The subtler problem is interleaving. Two people type two different words at the
same spot, and a naive identifier scheme can produce hweolrllod instead of
helloworld or worldhello. Both replicas show the same thing, so technically it
converged. It is still wrong. Convergence is not correctness, and Fugue and YATA
both exist largely to deal with this. It is a good reminder that the guarantee a
CRDT gives you is narrower than it first sounds.
Where ProseMirror fits
ProseMirror is not a CRDT and does not try to be. It is a rich text editing framework built on a strict model, and the separation matters if you want to combine the two.
A ProseMirror document is an immutable tree that has to conform to a schema, which
declares which nodes may contain which other nodes. You cannot produce an invalid
document, because the schema will not let you. Changes are Steps, each one
invertible, applied through a Transaction. ProseMirror also ships its own collab
module, which implements OT with a central authority.
Tiptap, which is what most people actually work with, is a friendlier API on top of ProseMirror. Extensions, sensible defaults, React and Vue bindings. Underneath it is ProseMirror, and every Tiptap document is a ProseMirror document. That is good news. When something behaves strangely, the answer is almost always in ProseMirror's documentation rather than Tiptap's.
To make Tiptap collaborative with CRDTs you use y-prosemirror, which binds a Yjs
document to the editor state.
The layering is the whole point. ProseMirror owns document validity and editing
semantics. Yjs owns convergence. Neither one knows much about the other, and
y-prosemirror is the translation between them.
Two things catch everybody here. Undo has to be the CRDT's undo manager, not ProseMirror's history plugin, otherwise Alice pressing Ctrl-Z undoes Bob's typing. And cursor positions have to be stored as relative positions anchored to CRDT identifiers rather than integer offsets, otherwise every remote edit teleports your collaborator's cursor somewhere random.
So why doesn't Google Docs use this?
Google Docs uses Operational Transformation and has done since Google Wave. It is a fair question why the most successful collaborative editor ever built skipped the approach that solves the problem more elegantly.
Part of it is timing. Docs came before practical sequence CRDTs existed. The foundational papers arrived after it shipped, and nobody rewrites the core of a product with that many users for architectural elegance.
The real answer is that CRDTs solve a problem Google does not have. A CRDT earns its overhead when there is no authority. Peer to peer, offline first, local first, unreliable connections. Google Docs has a server. It is always there, it is very good, and it can put operations into a single order. Once you trust a server, OT is cheaper. No per character identifiers, no tombstones, smaller documents, smaller payloads.
There is a second reason that I think gets underrated. OT preserves intent in a way convergence alone does not. A CRDT guarantees every replica ends up identical. It does not guarantee the result is what either person meant, which is the interleaving problem again. A central server transforming operations against a known history has more information to work with, and can make better decisions about what the user was actually trying to do.
The rule I would give someone starting today:
If you control the server and your users are online, OT is probably simpler and
cheaper, and ProseMirror's own collab module is well built and underused.
If you need offline editing, peer to peer sync, local first behavior, or "works on terrible wifi" is a feature rather than an accident, use a CRDT, and use Yjs rather than writing your own.
For Scriptyl the choice made itself. A first assistant director marking up a shooting schedule in a basement with no signal, whose changes have to merge cleanly with the line producer's an hour later, is exactly the shape of problem CRDTs exist for. If I had been building a Google Docs competitor for office workers on fiber, I would probably have picked OT and had an easier year.
What I would tell myself at the start
The math is the fun part and the smallest part. Once you have internalized "identifiers instead of indices, sorted", you have the core of it, and it really is elegant. One of those ideas that makes a hard problem look easy afterwards.
The rest of the year goes on presence and awareness, undo semantics, cursor mapping, load times as history grows, schema migrations across documents that exist in a hundred half synced replicas, and explaining to people why a document containing two pages of text is four megabytes.
Use Yjs. It is excellent, it is fast, and Kevin Jahns has already thought about your edge case. The interesting work is never in the CRDT itself. It is in everything you have to build around it.