Key takeaways: If offline isn't the primary path, it isn't offline-first. The local database is the source of truth and sync is a layer on top of it, not the reverse. op-sqlite + Drizzle is our production combination on Expo SDK 57. Migrations have to run unattended on thousands of phones you'll never touch. One of our original storage choices didn't survive contact with real features, and that story is below.
The Constraint That Picked the Architecture
FieldDojo gets used on job sites: basements, mechanical rooms, rural builds with one bar of signal. Someone opens it to size a wire run, and "retrying…" is not an acceptable answer.
That constraint settles the architecture argument before it starts. This isn't caching added for resilience. The local database is the application state, every feature works against it, and the network may or may not show up. Most tutorials treat offline support as a layer you add to a connected app; in a genuinely offline-first app the dependency runs the other way.
Why op-sqlite (and When expo-sqlite Is Fine)
First, this is a decision record and not a takedown. expo-sqlite is a reasonable choice for lighter needs, lives inside the Expo SDK, and requires nothing extra. We picked op-sqlite for two reasons that matter at FieldDojo's shape:
| Concern | What tipped it |
|---|---|
| Performance headroom | op-sqlite is JSI-backed and built for throughput; calculation history grows unbounded, and history search had to stay instant on old Android hardware |
| ORM fit | First-class pairing with Drizzle — typed schema, migrations, and queries with no bridge-serialization overhead in the hot path |
If your app stores a few settings and a small list, none of this margin matters, and the built-in module is the simpler dependency.
Drizzle: Typed Schema, and Migrations at a Distance
The underrated half of the stack is migrations. A backend database migrates once, on a server you control, with a rollback plan. A mobile database migrates thousands of times, on phones you'll never see, whenever a user gets around to updating the app, sometimes skipping several versions at once.
Drizzle gives us versioned migrations that run at startup, before any feature touches the database. The operating rules we hold ourselves to:
- Every schema change ships as a migration; the app never assumes a fresh install.
- Migrations run forward-only and must be cheap — a user opening the app to make one calculation shouldn't wait on a rebuild.
- The migration path from any released version must be tested, because in the field every released version is still out there.
The real test of this machinery was one we inflicted on ourselves.
The Choice That Didn't Survive: History in MMKV
FieldDojo v1 kept calculation history in MMKV, the fast key-value store we still use for preferences. It was the quick choice and it worked, right up until real features arrived: restoring a history item into a calculator, re-running it with changed inputs, filtering by trade. Those are queries, and a key-value store answers queries by loading everything and pretending.
So history moved into SQLite. Users already had history at that point, which meant the move shipped as a one-time startup migration that lifts the legacy MMKV data into the new schema on existing installs. It ran quietly across the installed base and nobody lost anything. But we'd rather have made the boring choice first.
Three Kinds of Data, Three Homes
The architecture that came out of all this sorts every piece of data by its shape:
| Data | Home | Why |
|---|---|---|
| User data — calculations, projects, history | SQLite (op-sqlite + Drizzle) | Queryable, durable, migratable; the source of truth |
| Reference data — NEC/ASHRAE/UPC/IRC tables, six regional code sets | Structured data inside the app binary | Must exist with zero connectivity from first launch; versioned with the code year and updated via app releases |
| Preferences — unit system, default trade | MMKV | Tiny, hot, never queried |
The middle row surprises people: the code tables never touch the network. Downloading reference data on first run would put a network dependency exactly where the no-signal constraint says there can't be one.
Sync Is a Layer, Never a Replacement
FieldDojo's Pro tier adds cloud sync, built as a mirror of the same local store rather than a second source of truth. A sync queue records local changes and reconciles with the backend when a connection returns, and conflicts resolve server-wins with versioning. The policy is deliberately boring. Calculation history is append-mostly, concurrent edits of the same record are rare, and no outcome of a history conflict is worth building a merge UI for.
The dependency direction is the part worth copying. Signed-out users get the complete app, and when sync arrived later it changed nothing about the offline architecture.
What Offline-First Costs, and When to Pay It
Offline-first added the sync queue, the conflict policy, the migration discipline, and the in-binary reference data to the cost of the build. That's real money. It's worth spending when losing signal is a normal part of using your product — a job site, a flight, a warehouse floor. If your users will tolerate a spinner, a plain API-backed app is cheaper and simpler, and you should build that instead.
If offline genuinely is the product, this is the stack we'd pick again, and FieldDojo is the working example. If you're building something like that, get in touch.