An offline-first app treats the database on the phone as the source of truth for everything the user sees, and treats the server as something it synchronises with when it can. That takes four parts: a local database, a queue of pending changes (an outbox), a way to pull server changes since the last sync, and a written rule for every kind of conflict. Background sync helps, but on both iOS and Android the operating system decides when it runs, so the design must work even if sync only happens while the app is open.
Offline-capable is not the same as offline-first
Many apps are "offline-capable": they cache the last screen and show an error when you try to save. That is fine for a news app. It fails the delivery driver in a basement car park, the technician in a plant room or the sales rep on a rural highway, who need to finish the job and move on.
Offline-first reverses the flow. Every screen reads from the local database. Every save writes to the local database and adds an entry to the outbox. The network is a background concern. The user never waits for a request to finish before carrying on, and the interface shows what is still waiting to sync.
It costs more to build, so use it only where the user's work cannot wait for a signal.
The local database
Use a real embedded database, not a key-value cache. SQLite is the default choice on both platforms and is available in every major toolkit: Room on Android, Core Data or SwiftData on iOS, Drift in Flutter, and SQLite-backed libraries such as WatermelonDB or op-sqlite in React Native. Capacitor apps can use a SQLite plugin rather than browser storage, which the browser may clear under pressure.
Decisions to take early:
- What goes on the device. Sync the user's working set, not the whole database: today's and tomorrow's jobs, the customers on those jobs, the product catalogue they may need. A clear rule such as "my assigned jobs for the next seven days" keeps the device fast and limits exposure if a phone is lost.
- Client-generated IDs. Records created offline need an identity before the server sees them. Give each one a UUID on the device and keep a mapping to the server ID once it is known.
- Schema migrations. Phones will run old versions of your app with unsent data in them. Every release must migrate the local schema without losing the outbox.
- Encryption at rest for anything sensitive, with the key held in the Keychain or Android Keystore.
The sync queue: push and pull
Push works from the outbox. Each entry records the operation, the record, the changed fields, the version of the record the user started from, and an idempotency key. The sync worker sends entries in order and removes each one only when the server confirms it. The idempotency key matters because mobile networks drop responses: the server receives the change, the phone never hears back and sends it again. With the key, the server recognises the repeat and does not create a duplicate delivery or a second payment.
Pull asks the server for everything changed since a cursor, usually a timestamp or a revision number stored after the last successful pull. The server must also report deletions, either as tombstone records or through a separate "deleted since" call, or deleted records will live on in phones forever. When the backend is Odoo, the write_date field on each record gives you a change cursor to work from, though deletions still need their own handling.
Photos, signatures and documents go in a separate upload queue. They are large, they fail differently, and a stalled photo upload should never hold up the small text change behind it.
outbox entry op_id 7f3c…e21 (idempotency key) entity task / local 5b9… → server 881 change stage: done, notes: "replaced valve" base_rev 17 status pending → sent → confirmed
Conflict resolution strategies
A conflict happens when the server's version of a record has changed since the version the user started from. There is no single right answer; pick one per entity type and write it down before building.
| Strategy | How it works | Use it for |
|---|---|---|
| Last writer wins | The most recent change overwrites. Simple, silently loses edits. | Low-value fields: preferences, a "last viewed" marker. |
| Field-level merge | Apply changes field by field; only a clash on the same field is a conflict. | Most business records, such as a job where the office changes the address and the technician adds notes. |
| Server authoritative | The server rejects the change with a reason; the app shows it to the user. | Anything with rules: stock availability, credit limits, a job reassigned to someone else. |
| Append-only events | Record actions ("arrived", "used 2 units", "signed") rather than final state. Events rarely conflict. | Timesheets, stock consumption, check-ins, audit trails. |
| CRDTs | Data types that merge automatically and consistently. | Shared documents and collaborative text. Usually more than business forms need. |
Do not rely on phone clocks to order changes. Devices drift and some people set their clocks by hand. Order by server revision, and keep the device timestamp only as information.
Background sync limits on iOS and Android
The most common wrong assumption is that the app will quietly sync every few minutes in the background. Neither platform promises that.
iOS
- BGAppRefreshTask and BGProcessingTask let you ask for background time. The system decides whether and when to grant it, based on battery, network and how often the user opens the app.
- BGContinuedProcessingTask, new in iOS 26, lets a task the user started in the app, such as "sync now" or an export, carry on after they switch away, with system progress UI the user can cancel. It must begin from an explicit user action.
- Background URLSession transfers let the system finish large uploads, such as job photos, after the app is suspended.
Android
- WorkManager is the recommended API. It persists scheduled work across restarts and reboots, supports constraints such as "only on a network", and respects Doze and battery saving.
- Periodic work cannot run more often than every 15 minutes, and Doze can defer it further. Expedited work is for important jobs that finish within a few minutes.
- Some manufacturers add their own battery optimisation that stops background work more aggressively. For company devices, exclude the app through device management.
Web apps have it hardest: the Background Sync API is not available in Safari, so a PWA on iPhone syncs only while it is open. The practical answer on every platform is the same. Sync on app open, on return to the foreground, when connectivity comes back and on "sync now"; treat background runs as a bonus.
Build or buy the sync layer
Sync engines and backend-as-a-service products can save months of work, and for some apps they are the right call. Check two things before committing. First, can the product talk to your real system of record, such as Odoo, or will you end up running a second database and syncing that too? Second, what happens if the vendor changes course: MongoDB deprecated Atlas Device Sync, the sync service behind the Realm SDKs, in September 2024 and switched it off on 30 September 2025, leaving teams to rebuild their sync layer.
A hand-built outbox and change feed on SQLite and your own API is more work at the start and fully under your control. For field apps against an ERP, that is usually what we build.
Test sync the way users break it: airplane mode mid-save, killing the app during upload, two devices editing the same job, a phone left offline for a week, an app update installed with a full outbox. For the Odoo-specific side, including which API to call and how to authenticate, read mobile apps for Odoo field teams, or talk to us about your field process.
Questions we get asked
What is an offline-first mobile app?
An offline-first app reads and writes to a database on the phone for every action, and syncs with the server in the background whenever a connection is available. Users can finish their work without signal, and the app shows which changes are still waiting to upload. It differs from an app that merely caches screens, which shows errors when you try to save while offline.
How do offline apps handle conflicting edits?
Each synced change carries the version of the record it started from. If the server's version has moved on, the app applies a rule chosen for that type of record: merge changes field by field, let the server reject changes that break business rules, keep the latest change for unimportant fields, or record actions as events that do not conflict. The rule should be decided per entity before development starts.
Can an iPhone app sync data in the background?
Only when iOS allows it. Background refresh and processing tasks are scheduled at the system's discretion, depending on battery, network and how often the app is used. Since iOS 26, a sync the user starts can continue in the background with a progress indicator. Large uploads can finish through background transfer sessions. Design the app to sync reliably whenever it is opened, and treat background runs as a bonus.
Which database should an offline mobile app use?
SQLite, in almost all cases. It is embedded, reliable and supported by every mobile toolkit: Room on Android, Core Data or SwiftData on iOS, Drift in Flutter, and SQLite libraries for React Native and Capacitor. Avoid relying on browser storage or simple key-value stores for unsent work, because they lack queries and migrations and, in a web view, can be cleared by the system.