To move focus out of the editor, press Escape and then Tab.
Blog
· 7 min read

Why we didn't reach for CRDTs

If you're looking for excitement in your life, try adding multi-user sharing and co-editing to your notes app.

For a few months Snownotes would sometimes stop you to say a note had changed somewhere else, and ask which version you wanted to keep. Sometimes that was fair enough, and you had edited the note on your phone that morning and forgotten. Often it wasn't. Nothing had changed anywhere, and if you clicked "load the server version" you got back a document identical to the one already on your screen.

We wanted it gone quickly. Not because any single interruption cost much, but because after the third or fourth false alarm people stop reading these things, and sooner or later one of them is real.

The obvious answer

Ask anyone how to stop conflicts in a document editor and you get the same answer, which is a good answer: use a CRDT. The acronym stands for conflict-free replicated data type, which is a mouthful for a reasonably intuitive idea. It's a way of storing data so that two copies edited separately can always be merged back together, in any order, without anyone being asked to choose. Yjs is the mature JavaScript implementation, there is a ready-made binding for our editor, and adopting it would have deleted the version number we use to spot conflicts, the merge code, and the banner in one go. Most of what people mean by local-first software - real offline editing, sync between your own devices, several people in a document at once - is built on top of this.

We looked at it properly, including the parts that get genuinely hard, and then didn't do it. Not because it wouldn't have worked. Not because it was too hard. Not because it'd meant rewriting large parts of the code base (well...).

What changed our minds was looking at how the app actually gets used. Snownotes is mostly a one-person tool: you, a laptop, a phone, maybe a tablet. Our users do share notes, but two people typing into the same note within the same minute is rare (in fact, in our logs, incredibly rare), and two people typing into the same paragraph is rarer still.

That is awkward, because preventing exactly that collision is what a CRDT is for. The bill, meanwhile, was easy to add up. Notes are stored as text in a database column, and a CRDT wants an opaque binary format instead. We send changes to your other devices in one direction only, and we would need a channel that works both ways. Version history and search both read that text column directly, so both would need rewriting to work against a blob they can't read. That's a serious piece of work, to solve something most of our users don't run into.

So the question became what was actually going wrong, since it clearly wasn't the merging.

What was actually going wrong

We already had decent merge code. When two versions of a note have both moved on, it compares each of them against the version they started from and works out what changed on each side - a three-way merge, the same approach git uses, built here on diff3 and taught to work a paragraph at a time rather than a character at a time.

It was hardly ever running. The code around it kept deciding that merging wasn't safe right now, and showing the banner instead.

That caution was justified, and we had caused it ourselves. A three-way merge is only as good as the version you hand it as the common starting point, and ours was wrong. Every time the editor sent a save, it immediately recorded the contents of that save as the new starting point, before the server had said anything back.

So the thing we were calling "the last version both sides agreed on" was really "the last thing this tab hoped the server would accept". Merging against that gives you nonsense, and whoever wrote the surrounding code had evidently noticed, because it was full of conditions that skipped the merge whenever the starting point was most likely to be wrong. Those were the exact moments a merge would have been useful.

When it was wrong in the other direction, the app compared your document against a version that had never existed on the server, concluded the two had diverged, and told you so. That's where the false alarms came from. We had spent a while looking for a race condition or a problem in the sync layer, and there wasn't one - we'd written down the wrong value and then built defences around the mistake.

Three strings and a decision

The fix was to record the starting point only when the server confirms it - when a note loads, when a save comes back successful, and when we take something from the server - and never when a save goes out.

Once you can trust that value, the skip conditions stop being necessary, and what's left is small enough to write out in full. Given the version you both started from, what's in your editor, and what's on the server:

Situation What happens
Server matches the base Nothing moved there. Keep your text, refresh the anchor.
Editor matches the base You did not move. Take the server's version.
Both moved, clean merge Merge silently, cursor and undo history intact.
Both moved, same block Ask. This is a real conflict.

There is one more case. If both sides have moved on while you are mid-sentence, the app does nothing for the moment. Dropping remote text into the document under live keystrokes is the one operation here that can jump your cursor, or interrupt an input method editor - the layer that turns a run of keystrokes into a Chinese or Japanese character, and which does not react well to the document being rearranged halfway through. So it waits. Next time you pause, the autosave goes out, comes back stale, and the whole thing runs again with your hands still, usually merging without a word. Saving on its own never touches the editor, so saving was never the risky part.

That's the same reasoning behind the assistant proposing an edit rather than making one. Sync and AI share no code at all and both landed on the same rule, which is don't rewrite the paragraph someone is standing in.

The upshot is that the whole policy is now a function of three pieces of text with no other state involved, so we can test it without a browser, a server, or a second device.

What we gave up

The merge works a paragraph at a time, so if two people genuinely do edit the same paragraph at once, you still get the banner. That's a real limitation, and it's the one thing a CRDT would have handled properly. We wrote it into the decision record as the boundary: if real-time collaboration turns into something people actually ask for, that's when we come back to this.

For now we're comfortable, because the banner went from a weekly irritation to something you rarely see, and when you do see it there really are two different versions. The ordinary case, where your phone saved something while the laptop was shut, now merges without mentioning it.

Looking back

The pull towards the interesting fix was strong. Conflicts are a hard problem, CRDTs are the serious answer to it, and there's a version of this where we spent two months rebuilding the storage layer and wrote a much better blog post about it.

The actual bug was a couple of lines recording the wrong value at the wrong moment. It took far longer to find than to fix, which is normal, and it would have been easy to bury under a rewrite instead of noticing.