Collaborative Text Editing
without CRDTs or OT
Matthew Weidner
Local-First Conf 2025
Berlin
This Talk: Simple Collaborative Text Editing
About Me
Common Curriculum dev (2024-)
CMU PhD student (2019-)
What Now?
My experience: CRDTs are fun, but hard to use in practice!
I now prefer “server reconciliation” (à la Replicache/Zero & FPS games) – a fully general, DIY-able alternative.
But I thought collaborative text editing still needed fancy algorithms…
Until I saw an intriguing comment from Wim Cools (@wcools) about how the Thymer IDE works.
The Approach
Typing in a doc: Tell the server, “I want to insert some text here”.
Here is not at index 9 any more!
The brown fox
{
op: “insert”,
text: “ fox”,
at: ???
}
{
op: “insert”,
text: “ fox”,
at: 9 // index
}
The quick brown fox
🧓️
insert “ quick”
❌
Typing in a doc: Tell the server, “I want to insert some text here”.
Here is defined relative to a char, labeled w/ a UUID.
We “insert after” that char.
The brown fox
{
op: “insert”,
text: “fox”,
at: ???
}
{
op: “insert”,
text: “ fox”,
after: “207c35d3…”,
ids: [“b65bb6d2-…]
}
The quick brown fox
insert “ quick”
207c35d3-…
207c35d3-…
🧓️
😎
What we need
A local data structure that:
{
char: “T”,
id: “8e82e5b3…”,
}
{
char: “h”,
id: “97f9a58b…”,
}
{
id: “591f0a08…”,
isDeleted: true,
}
,
,
, …
isDeleted: false
isDeleted: false
Optimized & persistent version:
Articulated on npm
Doable in ~250 LoC. Try it yourself!
, …
Updating clients?
UUIDs + “insert after” tell the server where to insert new chars.�But what about other clients, who might be ahead of the server’s state?
Now we need CRDTs/OT, right?�Nope! Server reconciliation works here, as usual.
Op L1
State S
Op L2
Op L3
Pending local ops
State S
1
Op R
State S
2
Op L1
Op L2
Op L3
Op R
State S
3
Op R
Advantages
Can DIY instead of relying on someone else’s CRDT/OT lib.
Then add special features that your app needs:
…
Server-Optional?
Decentralized Collaboration
Non-server reconciliation:�All clients (eventually) agree on a total order of operations, instead of using a server’s receipt order.
Op A1
Op A2
Op A3
Op C1
Op C2
Op B1
Op B2
Op A1
Op A2
Op A3
🧓️
Op C1
Op C2
🐱
Op B1
Op B2
🤠
Non-server reconciliation + “insert after <id>” ops�→ decentralized collaborative text editing!
Decentralized Collaboration cont.
Q: Isn’t this a CRDT?�A: Yes, “non”-server reconciliation always is. See OpSets.
Q: So this particular choice of ops defines a text editing CRDT?�A: Yes! Order “insert after” ops by Lamport timestamps:
Q: What if we add formatting ops? E.g., “bold the text from here to there.”�A: With a server (+ server reconciliation), it’s nothing fancy.�In the decentralized case w/ Lamport timestamps:
≈ Causal Trees / RGA
≈ Peritext
Non-server reconciliation + “insert after <id>” ops�→ decentralized collaborative text editing!
Decentralized Collaboration cont.
Lamport timestamp order does weird things to offline work:
What if we instead stack the branches, like git rebase?
Used with our “insert after” strategy, this gives another text CRDT:
≈ Fugue!
🤔
😎
Non-server reconciliation + “insert after <id>” ops�→ decentralized collaborative text editing!
Recap
Recap
Collaborative text editing in three steps:
Articulated on npm
Bonus quests:
Backup Slides
Bibliography
General Lists
Besides text, the “insert after” (or “insert before”) approach is useful in many GUI lists where insert, delete, & move are the primary ops.
E.g. spreadsheet formula refs (“3C”): if you store row & column UUIDs instead of “3” and “C”, the formula will adjust automatically when row 2 is deleted.
Why Articulated?
Articulated omits the chars (you store them in ProseMirror etc.) and is a persistent data structure (for easy server reconciliation) but is otherwise API-equivalent.
However, it adds nontrivial rope-like optimizations:
Goal: Store a map from UUIDs to chars, w/ ops to insert & delete.
Simple impl is�Array<{ char, UUID, isDeleted }>.
{
char: “T”,
id: “8e82e5b3…”,
isDeleted: false
}
{
char: “h”,
id: “97f9a58b…”,
isDeleted: false
}
,
, …
Equivalence with Causal Trees / RGA
A
B
C
X
Y
Z
A -> AB -> ABC -> ABCZ -> ABCYZ -> ABCXYZ
Equivalence with Fugue
A
B
C
X
ABCzyZYX
Y
Z
y
z
All “inserted after” C