Building Synapse offline-first with Loro

denzen1 pts0 comments

Building Synapse offline-first with Loro — Synapse

July 24, 2026<br>Building Synapse offline-first with Loro<br>Synapse is a study app: courses, annotated<br>PDFs, generated exercises, a lexicon of notions. I wanted it to work on a train<br>with no signal (a thing that I’ve been doing a lot lately), to be able to edit<br>and make changes on a phone as well as on a laptop, and to keep those in sync<br>without anyone having to run a server for it. This is mostly the reason the<br>whole data layer looks the way it does.<br>An application is usually built using a database on a server and a client that<br>talks to it over HTTP. There is nothing wrong with that, but it makes “offline”<br>something you have to retrofit in later, usually the hard way, the same way<br>multiple device sync is not a problem you can simply solve after the fact. With<br>Synapse the source of truth lives on the device and the server is just another<br>peer.<br>Choosing CRDTs<br>If every device holds its own copy of the data and edits it independently, you<br>need a rule for merging those copies that never loses work and never needs a<br>human to resolve a conflict, which is precisely what a CRDT gives you. If two<br>people rename the same chapter, three devices reorder the same list, someone<br>edits offline for a week the merge is still defined, deterministic, and the same<br>whatever order the changes arrive in.<br>If you’re after technical details, Jake Lazaroff’s interactive intro to CRDTs is a good intro to how these types actually converge.<br>I went for Loro as a CRDT library. Each piece of Synapse is<br>a Loro document: a course, its chapters and materials live in one, annotations<br>for a PDF live in another, etc.<br>Reading and writing looks like working with plain maps and lists:<br>const doc = await workspace();<br>doc.getMap("courses").set(courseId, { name, code, created_at });<br>await commit(doc, "courses", changeTag.course.add(name)); The commit wrapper persists immediately and tags the change.<br>What offline-first actually gets you<br>There is no loading spinner between a tap and the data, because the data is<br>already here. There is no “you are offline” mode, because being offline is the<br>normal case and being online is the exception. A fresh install with no server is<br>a complete and usable app. When you do point it at a server, or another device<br>on the same network, it converges without asking you anything.<br>The case for putting the data on the device is made better than I could by Ink &<br>Switch in Local-first software. The<br>short version is that your data should outlive any company’s servers, work<br>without a network, and still sync across your devices, and CRDTs are the tool<br>that makes that last part automatic.<br>Modern browsers have all the tools required for offline-first. You can use local<br>storage for preferences, IndexedDB for persistence so the data can live on the<br>device and survive a reload without any server in the loop. The browser is in<br>charge of storing the bytes and the CRDT decides how two copies of them come<br>back together.<br>A readable change log and going back in time<br>Merging is what CRDTs are famous for, and Loro handles it well. It was a bit<br>harder to have some kind of change log in the app and make it readable.<br>Loro records every operation. If you want a human-facing history (“added a<br>highlight on slide 3”, “renamed the chapter Mechanics”), the raw operations are<br>too low-level (a rename is a handful of map writes, a reorder is list moves). So<br>I made it so every write carries a short, language-neutral tag, and the history<br>view turns these into meaningful text at display time.<br>Once you have a readable log, the next nice feature to have is to travel along<br>it. You can restore an old version at any time: pick a moment and the app puts<br>the data back the way it was then.<br>I have been fond of this idea for a long time. When state machines and reducers<br>went mainstream in the frontend world, around when Redux landed, a thing that<br>caught me was definitely not the boilerplate (ugh) but the consequence: if every<br>change is a described, ordered step over immutable state, you can replay them,<br>step backwards, and watch the app move through its own past. Time-travelling<br>debuggers made that concrete and I found exciting the idea to be able to hunt a<br>bug back to the step that caused it, even if I must admit I rarely actually used<br>it.<br>Loro has a checkout method that moves the document to any point in its<br>history, so you can preview what things looked like at a given time and, if you<br>like that version, restore it. For a study app that means an accidental delete<br>or a bad reorder is never final, you can go look at last week and bring it back.<br>Maybe overkill for a study app if you ask me honestly, but Loro made it so cheap<br>to implement that it was not a bad feature to leave in.<br>Adding sync without a server<br>Two devices on the same network find each other over mDNS and exchange changes<br>directly over a socket. The exchange is the same idempotent merge every other<br>path uses, so I did not need special cases: a file export, a...

offline loro data synapse server first

Related Articles