Local-First Architecture for Solo Apps When CRDTs Help and When They Hurt
- Local-first cut my note app's perceived latency to zero across 3 devices
- Yjs and Automerge solve sync conflicts you may not actually have
- ElectricSQL saved 6 weeks versus hand-rolling CRDT plumbing
- Ship a plain server first, add local-first only when offline is the feature
I built two apps with local-first architecture and one without. The one that needed it feels instant on 3 devices with zero spinners. The one that did not need it cost me a lot of extra effort and gave users nothing. Here is where the line actually sits.
What Local-First Actually Buys You
Local-first means the app writes to a local store first, renders from that store, and syncs to the server in the background. The user never waits for the network. That is the whole pitch, and when it fits, it is genuinely magic.
I built a note-taking app this way. Every keystroke writes to IndexedDB. The UI renders from that copy. Sync happens on a queue behind the scenes. Perceived latency dropped to zero. On a train with no signal, the app still works. When the phone reconnects, changes merge and appear on my laptop within a second or two. Users noticed. The retention numbers on the offline-heavy cohort were roughly 40 percent higher than the online-only version I shipped first.
The three tools I reached for were Yjs, Automerge, and ElectricSQL. Yjs is a CRDT library optimized for text and shared documents. Automerge is a more general CRDT with a nicer data model for structured JSON. ElectricSQL sits on top of Postgres and syncs a local SQLite replica, which is a very different shape of solution.
The key idea across all three is the conflict-free replicated data type. A CRDT is a data structure where two devices can edit the same thing offline and merge later without a server picking a winner. No lock, no last-write-wins clobbering. The math guarantees both edits survive in a sensible order.
That guarantee is powerful for exactly one class of problem: multiple devices or people editing the same mutable state while sometimes offline. Collaborative notes. A multi-device dashboard where I toggle a filter on my phone and expect it on my desktop. A shared task list two people poke at on a bad hotel network.
If that is your app, local-first is not overhead. It is the feature. Users feel it as speed and reliability, which are the two things they actually pay attention to. If you want the wider decision framework I use for tooling like this, the Claude Blueprint walks through how I scope a build before writing a line of sync code.
When CRDTs Are Wasted Work For Nothing
The second app was a small commerce dashboard. One user, one device most of the time, data that lives on the server and is read far more than written. I reached for a CRDT because I had just finished the note app and it felt like the modern way. That was a mistake that cost me a lot of wasted effort.
CRDTs are not free. The document only grows unless you compact it. Automerge documents I built accumulated history, and a list of 5,000 items ballooned to several megabytes on disk. I had to add a snapshot-and-truncate routine that took real effort to get right without dropping edits. Yjs handles this better with its update encoding, but you still think about garbage collection more than you want to.
Then there is the mental tax. Every piece of state becomes a CRDT type. You stop writing `items.push(x)` and start writing into a shared array with its own API. Debugging a merge that produced a weird order means reading about tombstones and Lamport timestamps at 1am. For an app that had no concurrent editing, all of that solved a problem I did not have.
The honest test is one question: do two writers ever touch the same record while one is offline? For my dashboard the answer was no. A plain server with optimistic UI would have given the same feel for one percent of the effort. Optimistic UI means you render the expected result immediately and reconcile when the server responds. That covers the "feels instant" half of local-first without any of the merge machinery.
Here is my rule now. Reads dominate and writes are single-writer? Skip CRDTs. Offline is a nice-to-have but not a promise? Skip CRDTs. Data is server-owned and the server is the source of truth? Skip CRDTs, use a cache and a mutation queue.
The trap is that CRDTs are intellectually interesting, and interesting is not the same as needed. I burned real effort proving that to myself. Build the boring version first. If users hit a wall that only merging solves, you will know, and you can add it then with actual evidence instead of a hunch.
Picking Between Yjs, Automerge, and ElectricSQL
These three get lumped together but they solve different shapes of problem, and picking wrong is where the wasted effort comes from.
Yjs is the one I reach for when the core object is a text document or a shared editing surface. It is tiny, fast, and the ecosystem around it is mature. There are ready bindings for common editors and a provider for WebSocket sync you can run yourself. If I were building anything resembling a collaborative writing tool, Yjs would be the default. The update format is compact, so bandwidth stays low even with chatty editing. The tradeoff is that its data model is lower-level. You are working with shared maps, arrays, and text types, and you build your app model on top.
Automerge is the choice when your state is structured JSON and you want the CRDT to feel like editing a plain object. The developer experience is nicer for app data (settings, lists, records) rather than freeform text. The cost, as I mentioned, is document size and performance at scale. Automerge 2 improved this a lot, but I still watch the byte count on anything with thousands of entries.
ElectricSQL is the odd one out and often the smartest pick for a solo builder. Instead of making you model everything as a CRDT, it syncs a local SQLite database against a Postgres server. You write normal SQL locally, it replicates in the background, and conflict resolution happens under the hood. For the note app, moving the structured metadata (folders, tags, timestamps) to ElectricSQL and keeping only the note body in Yjs cut my sync code roughly in half. That combination saved me about 6 weeks versus hand-rolling a CRDT layer for everything.
My decision tree is simple. Freeform collaborative text? Yjs. Structured app state that needs offline merge and you want an object model? Automerge. Relational data that is mostly server-owned but should work offline? ElectricSQL. Mixing two of them is normal and often correct. I do not treat this as a single-winner choice.
Whatever you pick, prototype the sync path on day one with two real devices and a flaky network. The happy path always looks fine. The reconnect path is where you learn whether you chose right.
The UX Details That Actually Matter
Once sync works, the app still lives or dies on a handful of UX details that have nothing to do with CRDT theory.
The first is the sync indicator. Users need to trust that their data is safe without being nagged. I settled on a quiet dot that goes from grey (syncing) to solid (synced) and only shows a real warning after 30 seconds of failed sync. Early on I showed a spinner on every write, which made the app feel slower than the online version it replaced. That was the opposite of the point.
The second is conflict presentation. CRDTs merge automatically, but automatic is not always what a human wants. If two devices edit the same note title offline, Yjs will interleave the characters and produce garbage nobody typed. For fields where interleaving is wrong (a title, a price, a single choice), I treat the field as last-write-wins with a timestamp rather than a CRDT text type. Use character-level merging only where character-level merging makes sense, which is prose and code, not form fields.
The third is initial load. A local-first app has to hydrate from the local store before it can render. On a cold start with a large document, that can be a visible pause. I split the initial sync so the visible viewport loads first and history streams in after. Time to interactive dropped from about 1.8 seconds to under 400 milliseconds on the note app.
The fourth is the multi-device story you actually promise. Cross-device sync is where local-first shines, and it is also where users file the angriest reports when it lags. I schedule my own status posts about updates through Buffer so I can tell users exactly when a sync fix ships, which cut the "is it broken" messages noticeably. And if you sell the app itself, running the storefront on Shopify kept my checkout out of the local-first codebase entirely, which is one less thing to sync.
None of these are hard. They are just easy to skip while you are deep in merge logic, and they are what users judge.
Bottom Line
Local-first is not a badge you earn by using CRDTs. It is a tradeoff. When your app has real offline needs or multiple devices editing shared state, the right stack (Yjs for text, Automerge for structured state, ElectricSQL for relational data) makes the app feel instant and cuts your sync code dramatically. When it does not, CRDTs are a pile of clever plumbing that users never notice, and you would have been faster with a plain server plus optimistic UI.
My advice after building both ways: ship the boring server version first. Add local-first only when you can name the specific offline or multi-device moment it fixes. Prototype the reconnect path on two real devices before you commit, because the happy path lies to you.
If you want the full framework I use to scope builds like this before writing code, the Claude Blueprint is where I keep it. Build the thing users feel, not the thing that reads well in an architecture diagram.
This article contains affiliate links. If you sign up through them, I may earn a small commission at no extra cost to you. (Ad)
Back to all articles