Alice and Bob are editing the same note. Both are offline, on a train or on a plane. Alice makes a word bold, Bob fixes a typo three words further along. Later the devices sync. The question that research and frameworks have been wrestling with for fifty years: what now?
Merge the two HTML strings naively and you end up with torn markup and lost text. The interesting question is not whether this can be solved, but at which level you break the text apart. That is exactly where the approaches differ.
Rather try it out right away?
A short look back
First, so that expectations are right: I am not an expert in this field. What follows is my rough overview as a developer who was looking for a solution that fits, not a textbook account. Anyone who wants to know precisely will find far more depth in the sources I link to.
Operational Transformation (OT), the technique behind Google Docs, is the classic from the late 80s. The idea: every edit is an operation (insert at position 12), and when two operations collide, one is transformed against the other. That works well as long as a central server puts the operations into an authoritative order. Without that referee OT quickly becomes fragile, and over a dumb transport like a file system or P2P you can hardly keep it under control.
CRDTs (Conflict-free Replicated Data Types) turn it around: no server, no transformation. In text CRDTs like Yjs or Automerge every single character gets an immutable ID. Merging then means sorting the IDs together deterministically. Nothing is lost, the order always converges. The price for that is metadata per character, tombstones for deleted characters (which garbage collection wants to get rid of eventually) and a binary wire format that is no longer human-readable.
Peritext (Ink & Switch, 2022) and Fugue (2023) add the finishing touches on top. Peritext elegantly solves how concurrent formatting moves along with the edges of the text. Fugue removes the last interleaving problem when two people insert at the same spot. Both are the current state of the art, and yet everything stays in the same cost family: per-character metadata, a runtime, a binary format. Newer systems like Loro or json-joy combine Fugue and Peritext into impressively fast libraries. They don’t change that arithmetic though.
Why none of them fit me
My data platform has a few hard constraints. The transport should be allowed to be dumb: copy a file, a P2P relay, end-to-end encrypted if needed. The server then only sees opaque bytes, all the logic lives on the clients. And because the apps have to be audit-proof (GoBD compliant), every transaction must be inspectable: uncompressed JSON, no binary blob, no runtime you have to carry around.
A character-level CRDT violates exactly these points. Yjs’ binary updates are the opposite of auditable, and that is precisely why I moved away from Yjs back then. But what instead?
My approach: one level deeper
The decisive observation was that I already had the right ingredient in the house. In my system a document is an ordered map of blocks, meaning paragraphs and headings. Every block carries a fractional key, a sort key that fits between any two neighbours. That way two people can insert at the same position at the same time, and both survive. Blocks therefore already merge without conflicts.
So why import a foreign algorithm? I simply apply the same recipe one level deeper. The content of a block becomes an ordered map itself, this time of spans. A span is exactly one token: a word, a space, or an inseparable atom like a mention.
"blockA": { "k": "a3", "t": "p",
"s": {
"w1": { "k": "a0", "x": "Hello" },
"w2": { "k": "a1", "x": " " },
"w3": { "k": "a2", "x": "World" }
},
"m": { "mk1": { "f": "a0", "e": "a0", "y": "b" } } // "Hello" in bold: from key a0 to a0
}Every span has its own sort key k and a text x. The formatting deliberately does not sit on the span, but as its own entry m on the block: a mark is a range from sort key f to e with a type y (b for bold). That is all. No merge handler, no sequence CRDT, not a single new line of merge code. A map like this is a perfectly ordinary nested record, and my existing last-write-wins logic merges it field by field all on its own.
Why this works well
Three things come practically for free:
- Different words merge. Alice changes word 3, Bob word 7. Those are different fields, so both survive. The smallest unit of conflict has shrunk from an entire block to a single word.
- Formatting does not collide with text. Because text (
xon the span) and mark (the rangemon the block) are separate CRDT fields, making a word bold and rewriting that same word both survive at once. - Newly typed text inherits the formatting. The neat part about using a range instead of a field: if Alice makes a sentence bold and Bob types a word right into the middle of it at the same time, that word gets a sort key between
fandeand is therefore automatically bold. That is exactly Peritext’s core idea, only with word keys as anchors instead of one ID per character. One detail I will honestly admit to skipping: Peritext lets a bold mark grow at the end, but not a link. For now I treat everything like the link, so if you type right at the edge, it stays unformatted. For my case that is enough. - Tiny, readable transactions. A one-word edit travels over the wire as a single span entry, as plain JSON you can read along with in the log.
The identity of the spans does not have to travel through the editor for that, because Tiptap knows nothing about word IDs. Instead, on commit the codec aligns the new tokens against its own old spans via LCS. Anything unchanged keeps its ID, a corrected word becomes a replacement with the same ID and new text, and insertions get fresh IDs with run-atomic keys. That keeps two concurrently inserted groups of words contiguous instead of interleaving them.
I did not invent anything here
So that no false impression arises: this is not a new invention, but a combination of ideas that have been around for a long time. The basic pattern, meaning blocks in an ordered map, properties as last-write-wins fields and order via a fractional index, sits in block-based editors like BlockSuite, for example. Notion does essentially the same thing, only one level coarser: it merges offline at the block level via last-write-wins, and if two people edit the same paragraph, only one version survives.
The idea of keeping formatting as its own field rather than on the character has been described as well. In Designing Data Structures for Collaborative Apps Matthew Weidner sketches exactly such a map of LWW registers, and the Peritext paper explicitly discusses the JSON variant before deciding, for good reasons, in favour of the more elaborate route at the character level. Both texts are worth reading if you want to go deeper.
My contribution is therefore less an algorithm than a classification. Out of these existing ideas I picked the variant that fits my constraints best. The one point where I deviate from the usual models is granularity: Notion stays with the whole block, the classic rich text CRDTs go all the way down to the single character. I put the line in between, at the word.
The trade-off
This is not free. If Alice and Bob retype exactly the same word at the same time, one of them wins via last-write-wins. That is deterministic, and the lost version stays reconstructable in the audit-proof log. A character-level CRDT would lose nothing here, and Notion makes the same decision one step up, just for an entire paragraph instead of a word. For notes though, which are mostly edited by one person and only occasionally collide, the word is the right granularity. I pay roughly two to three times the metadata compared to plain text, and that cost is tied to the document size, not to the editing history. Where even that would be too much, for instance an imported email that mostly just gets read anyway, a paragraph stays a single raw string at first and only falls apart into spans on the first edit (the Email import fill mode in the demo).
What I took away from this for myself is a simple rule of thumb: the granularity of the stored metadata is the granularity of the merge quality. Per-block metadata buys block-level merging, Yjs’ per-character IDs buy character-level merging, my per-word spans buy word-level merging. I did not have to import a sophisticated sequence CRDT to become usefully collaborative for my case. I only had to pick the granularity that suits my documents, and budget the metadata for it. The rest is pleasantly boring, auditable last-write-wins mechanics.
Disclaimer: This article was written with the help of AI. I reviewed and corrected it, and I have to admit the AI writes better than I do 😁
Published on September 20, 2026