Offline-first mobile apps: design, sync and conflicts
What should you know about building an offline first mobile app?
Offline-first means a local database, queued actions replayed in order, and written conflict rules; it decides the data model, so start there. The app reads and writes locally, a sync engine pushes and pulls in the background, and every record type has a rule for when two edits collide. Retrofitting this is a rewrite.
An offline first mobile app is one where the network is an optimisation, not a requirement. Every screen reads from a local database, every action is written locally and queued, and a sync engine moves changes to and from the server whenever a connection exists. It is the only design that works for drivers in basements, technicians in plant rooms, sales staff in rural districts and shoppers on trains. It is also a decision about your data model, your API and your server, which is why it must be made at the start. This article explains the architecture, the sync patterns, how to write conflict rules, and where teams get it wrong.
Why offline-first is a data-model decision
An online app calls an API, waits, and shows the result. An offline-first app must know, for every record, whether it is local-only, synced, or changed since last sync; what version the server last acknowledged; and what to do if the server disagrees. That metadata lives in the data model. The API must accept batches of changes with client-generated identifiers and return what changed on the server since a given point. The server must tolerate the same change arriving twice and changes arriving out of order. None of this can be bolted on to an app that was designed around request-response, which is why we settle it in the first week of any React Native build that has field users.
The four parts of an offline-first architecture
| Part | What it does | Typical choice in React Native | Design decision to make |
|---|---|---|---|
| Local database | Holds the working set for every screen; the source of truth on the device | SQLite-based stores or a reactive local database with observable queries | Which data is on the device, for how long, and how it is purged |
| Action queue | Records every user action as an intent with a client ID and timestamp | A durable queue table in the same database | Ordering, retries, and what happens when an action fails permanently |
| Sync engine | Pushes queued actions, pulls server changes, applies conflict rules | A background task with a cursor per collection | Push-before-pull or pull-before-push; batch sizes; backoff |
| Server sync API | Accepts idempotent batches, returns changes since a cursor | Versioned records with server timestamps and tombstones | Per-record versioning, soft deletes, and permission filtering of pulls |
Queued actions replayed in order
Treat every write as an action, not a state. "Mark job complete" with a job ID, a timestamp and a payload is an action; "set status to complete" is a state. Actions can be replayed, reasoned about and rejected individually, and they carry intent, which matters when two of them collide. The queue is durable and ordered per entity, so that two actions on the same job are applied in the order the user performed them, while actions on different jobs can be sent in parallel. Each action has a client-generated ID that the server uses to deduplicate, and a status the interface can show: pending, sent, acknowledged, failed. Permanent failures (the server rejected the action on validation or permission grounds) must surface to the user with a clear message and a way to correct, not vanish silently.
Conflict resolution sync: write the rules down
Conflicts happen when the same record is edited on two devices, or on a device and the server, between syncs. There is no universal answer; there is a rule per record type, and the rule must be written before the code. The common options:
- Last write wins by server timestamp: acceptable for low-value fields like notes, dangerous for anything financial
- Field-level merge: each field keeps its latest change; works when fields are independent, such as a customer's phone and address
- Server wins: the device's change is discarded and the user is told; right for records the office controls, like prices or assignments
- Client wins: the device's change overrides; right for records only that user can edit, like their own checklist
- Append, never overwrite: model the record as a log of events (visits, readings, photos) so there is nothing to conflict
- Escalate: keep both versions and ask a person; reserved for rare, high-value cases
The append-only option deserves more use than it gets. A job's history as a list of events (arrived, started, parts used, signed, completed) never conflicts; the current state is derived from the events. The same idea underpins the ledger designs we use for payments in super apps.
Sync engine details that decide reliability
Push local actions before pulling server changes, so the server sees the device's intent before the device applies the server's view. Pull with a cursor per collection rather than a global timestamp, because collections change at different rates and partial failures should not restart everything. Batch pushes so a long offline period does not become a single enormous request. Retry with exponential backoff and jitter, and only on retryable errors; a validation error retried forever blocks the queue behind it. Run sync in the background when the platform allows and on app foreground always. Show a sync status somewhere quiet, with a way to see what is pending, because field users will ask.
Attachments and photos
Photos, signatures and documents are the largest part of most field apps' sync volume. Store them locally with a reference in the action, upload them separately through resumable uploads, and let the action be acknowledged before the attachment finishes if the server allows it. Compress on device with a quality the business has approved, and purge local copies after confirmed upload.
Local database mobile choices
For React Native, SQLite remains the dependable base, either directly or through a reactive layer that lets screens observe queries and re-render when local data changes. Reactive queries are what make an offline-first app feel instant: the screen updates the moment the local write lands, and again when sync changes it. Encrypt the database on device where the data is sensitive, and design a purge policy so that a driver's phone does not accumulate a year of history. Keep the schema migration story in mind from the start; an app in the field will run several versions at once, and each must be able to upgrade its local database without losing queued actions.
Testing offline behaviour
Offline bugs do not appear in the office on Wi-Fi. Test with network conditioning (drop, latency, intermittent), with the app killed mid-sync, with the clock wrong on the device, and with two devices editing the same record. Write automated tests for the conflict rules per record type, because those rules are the contract with the business. Field pilots with a handful of real users on real networks for a couple of weeks find the remaining cases.
A worked example
A last-mile logistics operator's drivers worked through dense urban areas with unreliable connectivity, and the previous app lost deliveries whenever the network dropped during proof-of-delivery capture. We rebuilt the driver app offline-first: the day's route and stops loaded into a local database at the start of the shift, every scan, photo, signature and cash entry became a queued action, and sync ran continuously in the background with push-before-pull. Conflict rules were written per record: stop status was append-only events, route changes from dispatch were server-wins with a notification to the driver, and cash collected was client-wins with a reconciliation on return. Deliveries stopped going missing, dispatch saw progress within seconds of connectivity returning, and the sync status screen ended the support calls about "did it save". The full story is in the dispatch platform and offline-first driver app case study.
Team and timeline
An offline-first app is usually two mobile engineers, one of whom owns the sync engine and local database, a backend engineer for the sync API and versioning, a designer, and a QA engineer with a network-conditioning kit, over ten to fourteen weeks to a field-ready release; the sync layer and conflict rules take the first three to four weeks. It falls under our React Native mobile development service from $17,500 / ₹11.2L, with the sync API under API development and integrations from $7,000 / ₹4.4L. Field rollout and post-launch fixes sit under a Standard Care Plan at $2,500 / ₹1,60,000 a month. See the pricing page.
Before you start: a checklist
- List every record type the app touches and decide which live on the device
- Write the conflict rule for each record type and get the business to sign it
- Define actions, not states, for every write the user can make
- Design the server sync API with client IDs, cursors, versions and tombstones
- Decide the purge and encryption policy for the local database
- Plan attachment upload separately from record sync
- Set up network-conditioning tests and a two-device conflict test
- Choose the pilot users and the real-network trial period
Glossary
- Cursor: a marker the client sends to ask for changes since its last successful pull
- Tombstone: a record kept after deletion so that other devices learn it was deleted
- Idempotent: an operation that has the same effect whether applied once or many times
- Reactive query: a database query that notifies the screen when its results change
- Push-before-pull: sending local changes before applying remote ones so intent is preserved
- Event sourcing: storing a record as its history of events and deriving current state from them
Related reading
Read field service apps: what technicians actually need for the features that sit on top of this architecture, building an offline-first driver app for the logistics-specific version, and React Native vs Flutter vs native for the framework decision. Android's offline-first guidance is a useful primary reference for the platform's view of the same patterns.
Decide the data model and the conflict rules before the first screen, and offline-first becomes an architecture rather than a bug list.
Frequently asked questions
Do we need offline-first if our users usually have connectivity?
▾
If any core action happens where the network can drop, yes; "usually" means the failures land on your most important moments. Read-only apps in offices can stay online-first.
How do you handle conflicts in offline sync?
▾
With a written rule per record type: last write wins, field merge, server wins, client wins, append-only, or escalate. The rule is tested automatically and signed off by the business.
How long does an offline-first React Native app take?
▾
Ten to fourteen weeks to a field-ready release, with the sync engine and conflict rules in the first month. See our React Native service and the pricing page.