Journal — February 15, 2026 · 5 min read

Offline-First Mobile Architecture

How an offline-first mobile app really gets built: local database as source of truth, a mutation outbox with idempotency keys, client-generated IDs, sync cursors, and the UX that's harder than sync.

The naive offline-first design is a cache. Fetch from the API, write the response to local storage, and read the stale copy when the network is gone. Everyone builds this first. It falls over the moment a user tries to change something offline, roughly ten minutes after launch.

What follows is the architecture that survives, in the order you'll be forced to discover it.

Step one: invert the source of truth

In a cache, the server is the truth and local storage is a copy. In an offline-first app, the local database is the truth and the server is a peer you reconcile with.

That sounds like philosophy. It's a code rule: no screen ever awaits the network. Every read comes from SQLite, WatermelonDB, Realm, whatever local store you picked. Every write commits locally and returns immediately. The network layer is a background process with no ability to block a render.

If any component has a loading spinner tied to an HTTP request for data you already hold locally, you haven't done this yet. That rule is about 60% of the work, and it's what makes the app feel fast even on good wifi.

Step two: an outbox for mutations

Every local write also appends to a mutation queue: operation type, payload, client timestamp, and an idempotency key generated at the moment of the action. A background worker drains it in order whenever connectivity exists, retrying with backoff until each entry is accepted or permanently rejected.

The idempotency key is not optional, and the reason is a bug I shipped.

A field-inspection app, technicians logging reports in buildings with no signal. One submitted on a flickering connection, the server processed it, the response never came back. Our retry fired. The server processed it again. The report existed twice, with different server IDs, so our dedupe-by-content check missed it too.

It took a support ticket and a very confused site manager to find. The fix was two hours: a UUID per mutation generated client-side, sent as a header, with a unique index on it server-side. Every retry then collapsed into the same row. I now write that constraint before the retry loop, because you cannot retry safely without it.

Step three: client-generated IDs

Related, and the second thing people get wrong.

If the server assigns IDs, an offline-created record has no ID until it syncs. You can't reference it, attach a child record to it, or deep-link to it. So you invent a placeholder and rewrite it everywhere on sync, touching every foreign key and every open screen. That's a bug factory.

Generate IDs on the client. UUID v7 is the good default: still a UUID, so no coordination needed, but the timestamp prefix makes it sortable, which keeps indexes from fragmenting the way v4 does.

Some backends resist this. Push back. Rewriting identity across a live object graph is far worse than trusting a client UUID with a uniqueness constraint behind it.

Step four: conflict resolution, honestly

Two devices edited the same record while both were offline. Three real options.

Last-write-wins. Whoever syncs last overwrites. Trivially simple, and it silently destroys work. Fine for records with one obvious owner. Not fine for anything shared.

Per-field merge. Track a modified timestamp per field, merge field by field, conflict only when two devices touched the same field. The sweet spot for most business apps: if one person changed the address and another the phone number, both survive. Costs you per-field metadata and some fiddly merge code.

CRDTs. Correct convergence with no coordination. Also a data structure that grows, a library the whole team must now understand, and debugging sessions that feel like reading a physics paper. Right for collaborative text and shared canvases; for a forms-and-lists app, more machinery than the problem deserves. I've picked per-field merge every time and haven't regretted it.

Choose per entity. A settings object and a shared document need different policies.

Step five: sync cursors, not timestamps

The obvious protocol is "give me everything changed since 2026-02-14T09:12:00Z". It works in development and betrays you in production, because it assumes client and server clocks agree. They don't. Phones drift, users change timezones mid-flight, and someone always has a device set to 2019.

Use an opaque cursor issued by the server. The client returns whatever token it was last given, the server decides what that means, and clock skew stops being your problem.

Deletes need tombstones, since a deleted row can't appear in a "changed since" list. A deleted_at column, rows that stay in the change feed as deletion markers, and a retention window after which they're purged. A device offline longer than that window must be forced into a full resync rather than quietly keeping records the rest of the world deleted.

The hard part is the UX

Everything above is a couple of sprints. The interface is where offline-first projects actually stall.

Optimistic UI needs a rollback story. The change appears instantly. When the server rejects it three minutes later, the user is on a different screen with no idea which action failed. A red toast reading "sync failed" is not a design, it's an apology.

Rejections need a queue the user can see. I treat rejected mutations as inbox items: what didn't go through, each with a retry or discard action.

Sync state should be visible without nagging. One quiet indicator with pending count and last successful sync. No modal, no banner. The promise of offline-first is that the network stops being the user's problem, and a spinner in their face breaks it.

Test with the network genuinely off

Simulator throttling is not airplane mode, and neither is the truly evil case: a captive portal, or a signal that completes a TCP handshake and then times out mid-request. That last one is where duplicate writes come from.

My test pass: airplane mode for the whole flow, a mid-request kill, a force-quit with a non-empty outbox, then reconnect. If the queue is on disk and the keys are idempotent, all four are boring. If either is missing, you find out here instead of from a site manager.

Most of this is framework-agnostic, though easier when you aren't fighting your tooling for background execution and native SQLite. On stack choice, see React Native vs Flutter and Expo vs bare React Native.

Offline-first isn't a feature for users in tunnels. It's a commitment to never letting network latency reach the interface, and apps that honour it feel better on perfect wifi. That, not the tunnels, is what pays for the sprints.