Offline is a design decision, not a feature
Most mobile apps treat the network as always present and add a cache when users complain. That works while connectivity is good and failures are rare. It stops working in a warehouse basement, on a farm at the edge of coverage, on a delivery route through patchy signal, or anywhere the cost of a lost action is higher than a retry. In those places the network is not an occasional absence; it is the normal condition, and an app that assumes otherwise fails in ways its users cannot see until their work is gone.
Offline-first architecture inverts the assumption. The device holds the data the user needs, the interface reads from the device, and the network becomes a channel for synchronising state rather than a dependency every screen waits on. The shift sounds small. It changes where data lives, how records are identified, what a write means and how the product behaves when two people change the same thing — which is why it is far cheaper to decide at the start than to retrofit.
The shape of the architecture
The reference architecture below lists the layers an offline-first data layer needs, what each is responsible for, and the failure that appears when the layer is missing — usually because the app began online-only and had offline behaviour added later.
Reference architecture
Layer | Responsibility | Failure mode when retrofitted |
|---|---|---|
Local store | The source of truth the interface reads from, typically SQLite through a platform or cross-platform wrapper | The app still reads from the network and caches, so every screen has two code paths |
Outbox | Every write becomes a durable, ordered operation before anything is sent | Writes go straight to the API and are lost when a request fails mid-flight |
Identity | Records receive client-generated IDs, such as UUIDv7, when created | Server-assigned IDs force placeholder IDs and a rewrite of every reference |
Idempotency | Each operation carries a key the server uses to ignore repeats | Retries create duplicate orders, payments or submissions |
Sync engine | Pushes the outbox and pulls changes since a cursor, in the background | Full refreshes on every launch, and battery use the platform will throttle |
Conflict policy | Decides, per data type, what happens when two edits meet | The last write silently wins and users lose work without knowing |
Schema migration | Moves local data forward without losing queued writes | An app update wipes the local database, and the unsynced work with it |
Session | Lets the user keep working when a token expires offline | Users are signed out in the field and their queue is stranded |
Observability | Reports queue depth, sync lag and failures from devices | Problems surface as support tickets weeks after they began |
Patterns that hold up
Write locally, then queue
A user action writes to the local store and appends an operation to an outbox in the same transaction. The interface updates immediately from the local store. A sync worker drains the outbox in order when connectivity allows. This outbox pattern means an action is never half-done: either both the local change and the queued operation exist, or neither does.
Generate identity on the device
When the server assigns IDs, an offline record has no permanent identity, and every reference to it — a line item on an order, a photo attached to an inspection — must be rewritten once the server replies. Generating time-ordered IDs on the device removes the problem at the root. Records are addressable from the moment they exist, and references never change.
Make every operation idempotent
Mobile networks fail after a request is sent and before the reply arrives. The client cannot tell whether the server acted, so it retries. Each queued operation therefore carries an idempotency key, and the server records the keys it has processed and returns the original result for a repeat. Without this, retries become duplicates, and in a payments or ordering flow duplicates are the most expensive bug there is.
Choose conflict policy per data type
There is no single correct conflict strategy. Preferences can take the last write. Independent fields on a record can merge field by field. Stock counts, balances and anything involving money should be server-authoritative, expressed as operations rather than overwritten values. Collaborative text may justify conflict-free replicated data types. The mistake is choosing one policy for everything, usually last-write-wins by default, and discovering its cost in the one table where it was wrong.
Sync deltas, not snapshots
Pull only what changed since the device's last cursor, and push only queued operations. Full refreshes are simple to build and ruinous at scale: they multiply server load by the number of devices and drain batteries that the operating system will then throttle.
Failure modes to design out
Trusting the device clock
Device clocks drift, change time zones and are sometimes set by hand. Ordering edits by device time produces conflicts resolved the wrong way round. Order by server-issued versions or hybrid logical clocks, and keep device time for display only.
Queues that outlive app versions
A queued operation written by version 3.1 may be sent by version 3.4. Operations need a version, the server must accept older shapes for a transition period, and migrations must carry the outbox forward rather than discarding it.
Media and large payloads
Photos and documents do not belong in the same queue as small records. They need their own resumable upload path, with the record referencing the file by its client-generated ID, so a slow upload never blocks the data behind it.
Background limits
Both platforms schedule background work on their own terms. Sync that relies on running whenever the app wishes will be deferred or killed; work with the platform schedulers and make every sync step safe to interrupt.
Renting the sync layer
Managed sync services are convenient and occasionally withdrawn — MongoDB deprecated Atlas Device Sync in 2024. If a vendor provides sync, keep the local data model and the operation format your own, so the exit is a new transport rather than a rewrite.
Testing it properly
Offline behaviour fails in sequences, not single requests, so test sequences: switch to aeroplane mode mid-write, kill the app during sync, edit the same record on two devices, update the app with a non-empty queue, and let a session expire while offline. Run these on real devices under network conditioning, and keep them in the regression suite.
Then measure what users experience in the field: how long a queued action waits before it syncs, how often a sync fails and is retried, and how large queues grow on the devices that stay offline longest. Those numbers show whether the architecture is working where it matters, not just in the test lab.
This is the data layer we build for field and mobile products across our Digital Products work, and it is what lets on-device models in Applied AI keep working where there is no signal at all — a constraint we meet most often in agriculture technology.