Lindsay Edwards

Synchronous is a feature: picking an embedded database

On this page

I built a small local-first tool called CooknCap, a desktop-ish thing that runs on your own machine and keeps its data in an embedded database. Nothing distributed, no server, one process. And picking the embedded database taught me something that runs against the usual instinct: synchronous was the feature, not the compromise.

The async in-memory version, and its lost writes#

I started with a WebAssembly build of SQLite. It has a lovely property for getting going: no native compiler needed, it just runs. But it holds the database in memory and needs an explicit “now save to disk” call to persist. Which means every code path that changes data has to remember to save afterwards, and the day one of them forgets, you have a lost write that only shows up after a restart. In user terms, something you had clearly saved was simply gone the next time you opened the app, with nothing to tell you it had happened. I spent more time than I want to admit chasing “where did that row go.”

That is a whole class of bug that exists purely because persistence was a separate, easy-to-forget step.

Sync native, and the bugs that disappeared#

Once the toolchain was in place, I switched to a native, synchronous SQLite binding. The payoff was not speed, it was the deletion of a category of mistakes:

  • Writes hit the disk natively. There is no “save the database” step to forget, because the write is the persistence.
  • The database calls are synchronous, so initialising it stopped being an async, awaited dance. In a single-process local app there is no event loop full of other requests to block, so “synchronous” costs you nothing and buys you code that reads top to bottom with no await ceremony and no interleaving to reason about.
  • Proper type definitions came with it, so a hand-rolled types file went in the bin.

“Async” is a benefit when you have other work to do while you wait. In a single-process tool with nothing else to interleave, it is pure ceremony, and synchronous code you cannot forget to persist is simply safer.

The tax you do pay#

It is not free. A native binding means a real compiler on every machine that builds the project, developer laptops and continuous-integration runners alike, and it pins you to the binary interface of the exact runtime version, so upgrades need a little care. In a package manager that blocks post-install build scripts by default, I had to explicitly allow the native module to build.

That is a real cost, and worth naming before you commit. But for a local-first, single-process app, it is the right trade: pay a bit of build-time complexity once, and delete an entire genus of “I forgot to flush” and races forever. The lesson I took wider is that async is a tool for a specific problem, concurrency, and reaching for it out of habit, in a place that has no concurrency, adds risk instead of removing it.

Keep reading