
Get the protocol right, the backend can come later
On this page
I was reading a local-first sync client, the part that keeps a device and a server in agreement. The client was good. Careful, even. Then I looked at the server it talked to, and the server kept everything in in-process maps that vanished on restart.
My first reaction was that the project was half-built. My second, better reaction was that it was built in the right order.
The client was the expensive part, done well#
Local-first means the device owns the truth and syncs it up when it can. That is harder than it sounds, and this client took it seriously.
It kept a pending-change log, so every local edit was recorded as something still owed to the server. It ran a pull-then-push cycle: take the server’s changes first, then send yours, which keeps the ordering sane. A re-entrancy flag guarded the cycle so a second sync could not start while the first was running.
It used monotonic sync cursors, a number that only ever moves forward, so each side knows what it has already seen. And it handled conflicts explicitly. When the server answered with a version conflict, a pluggable resolver callback decided what to do, defaulting to “server wins, drop the local edit”.
That default is not exciting, but it is a decision, written down, in code, at the exact spot conflicts happen.
The backend was a stub, on purpose#
The server was the opposite. It answered the protocol correctly, but it stored sync state in plain in-memory maps. Restart the process and every cursor, every change, every version reset to nothing. In a shipped product that would be data loss, every user’s synced work wiped on each restart. It is only harmless here because this is a prototype, and it stays a prototype precisely until the durable part is slotted in.
The desktop shell around it was the same shape. It registered its plugins, so the window opened and the app ran, but it exposed zero native commands. Nothing actually crossed from the web layer into the operating system yet.
Both are correct skeletons with the hard, durable part deliberately deferred. And I have learned not to sneer at that.
A stubbed backend behind a correct protocol is a placeholder. A messy protocol behind a solid backend is a rewrite. Only one of those gets more expensive with time.
Protocol first, because protocol is what you cannot change cheaply#
The reason to get the protocol right first is that the protocol is the contract between two things that ship separately. The moment the client and server are both in the wild, changing the shape of how they agree means migrating live devices you do not control.
The change log, the cursor, the conflict-resolution hook: those are the load-bearing decisions. Get them right and you can swap the in-memory maps for a real database later, behind the same wire format, and no client notices.
Do it the other way, ship a durable backend under a vague protocol, and you have persisted the wrong shape. Now you are migrating storage and the contract at once.
The desktop shell taught the flip side of the same lesson. “The window opens” is not “the native integration works”. Registering plugins is plumbing that lets the app boot. Exposing native commands is the actual bridge, and until those commands exist, the desktop version is a web app in a frame. Do not let a launching window convince you the hard part is done.
The lesson#
For local-first, spend your early, careful hours on the protocol: an explicit change log, a monotonic sync cursor, a named conflict-resolution hook. That is the part that is expensive to change once devices are out there, so it deserves the attention while it is still cheap.
A durable backend can slot in later behind a stable client. And a window that opens is a milestone, not a finish line. Get the contract right, and let the storage catch up on its own schedule.


