I recently spent time researching architectures for an offline-first app: a local database on the device, a remote source of truth, and changes flowing both ways. The libraries in this space — PowerSync, ElectricSQL, Replicache/Zero, RxDB and others — all make different trade-offs, but reading through their designs surfaced the same handful of hard problems. These are my notes.
The whiteboard version lies
Every sync diagram looks like this:
local DB ⇄ sync engine ⇄ remote DBThe arrows hide the three questions that actually define your architecture:
- What happens when both sides change the same row? (conflicts)
- How do you identify rows created offline? (identity)
- How does a delete on one side reach the other? (tombstones)
If a library's docs don't answer all three explicitly, keep reading until you find out — you will meet these in production either way.
Conflicts: pick a strategy you can explain
The common options, roughly in order of complexity:
- Last-write-wins (LWW). Simple, predictable, and silently drops data. Fine for "preferences", dangerous for anything a user typed.
- Field-level merge. Two devices editing different columns of the same row both win. Most engines that track changes per-column give you this nearly for free.
- Application-level resolution. The server replays intentions ("add 1 to quantity") rather than states ("quantity = 5"). Most correct, most work.
- CRDTs. Mathematically guaranteed convergence, at the cost of data-model constraints and storage overhead. Worth it for collaborative text; usually overkill for business CRUD.
The honest insight: for most apps, LWW per field plus a server-side audit log covers 95% of cases, and the audit log saves you when the other 5% file a support ticket.
Identity: UUIDs at the edge
Rows created offline can't wait for the server to assign an ID — anything referencing them would need rewriting after sync. Generate UUIDs (v4, or v7 if you want index-friendly ordering) on the device and make them the primary key everywhere. Sequential integer IDs and offline creation simply don't mix.
Deletes are not absence
If a device deletes a row while offline, the server can't distinguish "deleted" from "never synced" unless you keep a record. That's the tombstone: a soft-delete marker that propagates like any other change and gets garbage-collected after every client has seen it. Skipping tombstones is the classic source of "deleted items keep coming back" bugs.
Where the libraries differ
What I took from comparing the current crop:
- Postgres-centric engines (PowerSync, ElectricSQL) shine when the remote DB already exists and you want partial replicas of it on-device, declared via sync rules.
- Replicache-style engines push conflict resolution into your own mutators — more control, more responsibility.
- Local-first databases (RxDB and similar) start from the device side and treat the backend as replication target.
None of them removes the three hard problems. They just choose default answers — and the right library is the one whose defaults match what you'd have chosen anyway.
The takeaway
Offline-first is less a feature than a data-architecture commitment: client-generated IDs, change tracking, tombstones and an explicit conflict policy, decided up front. Retrofitting any of these onto a live schema is painful. Deciding them before the first migration is cheap.