Synapse devlog

Building Synapse offline-first with Loro

Synapse is a study app: courses, annotated PDFs, generated exercises, a lexicon of notions. I wanted it to work on a train with no signal (a thing that I’ve been doing a lot lately), to be able to edit and make changes on a phone as well as on a laptop, and to keep those in sync without anyone having to run a server for it. This is mostly the reason the whole data layer looks the way it does.

An application is usually built using a database on a server and a client that talks to it over HTTP. There is nothing wrong with that, but it makes “offline” something you have to retrofit in later, usually the hard way, the same way multiple device sync is not a problem you can simply solve after the fact. With Synapse the source of truth lives on the device and the server is just another peer.

Choosing CRDTs

If every device holds its own copy of the data and edits it independently, you need a rule for merging those copies that never loses work and never needs a human to resolve a conflict, which is precisely what a CRDT gives you. If two people rename the same chapter, three devices reorder the same list, someone edits offline for a week the merge is still defined, deterministic, and the same whatever order the changes arrive in.

If you’re after technical details, Jake Lazaroff’s interactive intro to CRDTs is a good intro to how these types actually converge.

I went for Loro as a CRDT library. Each piece of Synapse is a Loro document: a course, its chapters and materials live in one, annotations for a PDF live in another, etc.

Reading and writing looks like working with plain maps and lists:

const doc = await workspace();
doc.getMap("courses").set(courseId, { name, code, created_at });
await commit(doc, "courses", changeTag.course.add(name));

The commit wrapper persists immediately and tags the change.

What offline-first actually gets you

There is no loading spinner between a tap and the data, because the data is already here. There is no “you are offline” mode, because being offline is the normal case and being online is the exception. A fresh install with no server is a complete and usable app. When you do point it at a server, or another device on the same network, it converges without asking you anything.

The case for putting the data on the device is made better than I could by Ink & Switch in Local-first software. The short version is that your data should outlive any company’s servers, work without a network, and still sync across your devices, and CRDTs are the tool that makes that last part automatic.

Modern browsers have all the tools required for offline-first. You can use local storage for preferences, IndexedDB for persistence so the data can live on the device and survive a reload without any server in the loop. The browser is in charge of storing the bytes and the CRDT decides how two copies of them come back together.

A readable change log and going back in time

Merging is what CRDTs are famous for, and Loro handles it well. It was a bit harder to have some kind of change log in the app and make it readable.

Loro records every operation. If you want a human-facing history (“added a highlight on slide 3”, “renamed the chapter Mechanics”), the raw operations are too low-level (a rename is a handful of map writes, a reorder is list moves). So I made it so every write carries a short, language-neutral tag, and the history view turns these into meaningful text at display time.

Once you have a readable log, the next nice feature to have is to travel along it. You can restore an old version at any time: pick a moment and the app puts the data back the way it was then.

I have been fond of this idea for a long time. When state machines and reducers went mainstream in the frontend world, around when Redux landed, a thing that caught me was definitely not the boilerplate (ugh) but the consequence: if every change is a described, ordered step over immutable state, you can replay them, step backwards, and watch the app move through its own past. Time-travelling debuggers made that concrete and I found exciting the idea to be able to hunt a bug back to the step that caused it, even if I must admit I rarely actually used it.

Loro has a checkout method that moves the document to any point in its history, so you can preview what things looked like at a given time and, if you like that version, restore it. For a study app that means an accidental delete or a bad reorder is never final, you can go look at last week and bring it back. Maybe overkill for a study app if you ask me honestly, but Loro made it so cheap to implement that it was not a bad feature to leave in.

Adding sync without a server

Two devices on the same network find each other over mDNS and exchange changes directly over a socket. The exchange is the same idempotent merge every other path uses, so I did not need special cases: a file export, a server pull and a direct device sync all end in the same import.

Live discovery is the one piece a browser can’t do, because there is no raw sockets, nor way to announce oneself on the local network. To be able to do so, I wrapped the web app in a small Tauri shell. The UI stays the same code that runs in the browser, but the shell adds a thin native layer in Rust that advertises the device as a service and look for others so peers become discoverable without anyone typing an address.

mDNS is just here for convenience. The plainest way to sync two devices needs zero network: export the workspace as a zip pack, and import it somewhere else. Because import is that same idempotent merge, it does not matter how old the pack is or what the other device did in the meantime, the two simply converge. The pack is a full, portable copy of your data, so it doubles as a backup.

Loro can export the whole operation history since the document was born, or a shallow snapshot: just the current state with the past trimmed off.

The full history is what powers checkout and a merge with a device that has been offline for months, but it only grows, so a book you have annotated for a term makes for a heavy pack. That is why the default export is shallow, which keeps the pack small and is all you need to move your data or back it up. The full history is opt-in, for when you actually want to carry the timeline along.

A note of decentralized apps and the nature of the internet

The internet was designed to be decentralized. Its whole point was machines talking to each other as equals, with no one in the middle who had to be asked for permission, an ideal people keep trying to bring back: the fediverse, where independent servers federate as peers so no single company owns the network, whether over ActivityPub (Mastodon and friends) or atproto (Bluesky, tangled), and the IndieWeb, where you publish on your own domain first. What we mostly got instead is a handful of platforms that sit between us and each other, and once you depend on one, it tends to rot in a predictable direction. Cory Doctorow named that pattern enshittification: the service is good to users to attract them, then good to business customers at users’ expense, then good only to itself, because leaving is made hard enough that it can get away with it.

A local-first app dodges that trap almost by construction. Your data is on your device, in a format you can export, and it syncs peer to peer without routing through a company that could later decide to charge for it, mine it, or shut it off. There is no lock-in to withdraw, because there is nothing in the middle to hold your data hostage. If Synapse disappeared tomorrow, your courses and notes would still be sitting on your laptop, yours. That is not the whole answer to Doctorow’s plan to reverse enshittification, but it is the part a single app can actually do.

Consider a CRDT for your offline-first app

If you are building something offline-first, I would suggest giving CRDTs a look. It moves the work from resolving conflicts to shaping your data so the automatic merge is the one you actually want. You get back an app that is genuinely the user’s. It works with nothing behind it, syncs when you give it something to sync with, and as a bonus lets you walk back through its own past.

← All writing