How to design an offline-first mobile app: local storage choices, an outbox for writes, delta sync, conflict resolution strategies and testing on bad networks.
In this article
- 01What offline-first really means
- 02Choose local storage
- 03Or adopt a sync engine
- 04Writes: the outbox pattern
- 05Reads: delta sync with cursors and tombstones
- 06Resolve conflicts deliberately
- 07Design the interface for offline use
- 08Photos, files and large attachments
- 09Background sync and connectivity
- 10Storage, security and schema changes
- 11Testing offline behavior
What offline-first really means
An offline-first app treats the device's local database as the source the UI reads from and writes to, and treats the network as something that synchronizes in the background when available. The user can open the app, view data and record changes with no connection, and nothing is lost when the connection returns. Our offline-first explainer covers the concept briefly.
This is different from caching. A cached app shows old data while offline but cannot accept changes reliably. Offline-first matters for field workers, farms, warehouses, construction sites, rural healthcare and any app used underground or on the move. It also makes every app feel faster, because the UI never waits on the network.
Choose local storage
You need a real database on the device, not a key-value store holding large JSON blobs. SQLite is the dependable foundation, available everywhere, with higher-level layers on top for each platform:
Whatever you choose, design the local schema around the screens that read it. Index the columns used for filtering and sorting, because a list screen querying thousands of rows on a low-end phone needs indexes as much as a server does, and keep sync metadata such as version numbers and dirty flags in dedicated columns.
- Android: Room over SQLite
- iOS: SwiftData or Core Data, or SQLite through a library such as GRDB
- React Native: SQLite libraries such as expo-sqlite or op-sqlite, or WatermelonDB for large datasets
- Flutter: Drift over SQLite
- Cross-platform sync databases: Couchbase Lite or Realm-style object stores with built-in sync
Or adopt a sync engine
Building sync yourself is real work. Firebase Firestore has offline persistence built in, and several managed sync engines replicate a server database such as PostgreSQL into SQLite on the device. They save months when your data model fits their assumptions. Building your own makes sense when you need custom conflict rules, have an existing backend you cannot change or must control exactly what syncs to which user. Our Firebase vs Supabase comparison covers two popular backends.
Writes: the outbox pattern
When the user changes something, write it to the local database and, in the same transaction, add a record to an outbox table describing the change: the entity, the operation and the data. The UI updates immediately from the local database. A background sync process sends outbox entries to the server in order and removes each one once the server confirms it.
Generate IDs on the device as UUIDs, so new records can be created offline and referenced by other records before the server has seen them. Make every server endpoint that receives sync operations idempotent, because the same operation may be sent twice if the confirmation is lost. Our idempotency explainer covers the patterns.
Reads: delta sync with cursors and tombstones
Downloading everything on every sync does not scale. Instead, the client stores a cursor, such as the last server change timestamp or sequence number, and asks for changes since that cursor. The server returns created and updated records plus tombstones for deleted ones, so the client knows what to remove. Use a server-assigned sequence or timestamp for the cursor, never the device clock, which can be wrong.
- Paginate change feeds so a device returning after weeks offline does not fetch everything at once
- Sync only the data each user needs, scoped by permissions
- Run pull and push in a defined order, usually push first, then pull
- Keep sync state visible: pending, syncing, synced and failed
Resolve conflicts deliberately
Conflicts happen when two devices change the same record while one or both are offline. There is no universal answer, so choose a strategy per entity type:
- Last write wins, using server-received order: simple, acceptable for settings and low-value fields
- Field-level merge: combine changes to different fields of the same record, so a changed phone number and a changed address both survive
- Version checks: each record carries a version number; the server rejects stale updates and the app asks the user or applies a rule
- Append-only records: model events, such as readings or inspections, as new rows that never conflict
- CRDTs, through libraries such as Automerge or Yjs: for collaborative editing where every change must merge automatically
Design the interface for offline use
The interface should make offline work feel normal, not like an error. Update the screen immediately when the user saves, show a small indicator on records that are waiting to sync, and display when data was last synced. Reserve warnings for actions that truly need the server, such as payments or checking live stock, and disable those clearly while offline.
When a conflict or a rejected change does happen, tell the user in plain words which record was affected and what the app did about it. Silent data changes destroy trust faster than an honest message.
Photos, files and large attachments
Field apps often capture photos, signatures and documents offline. Store files in the app's private storage, record a reference in the local database and upload them separately from record sync, because files are large and fail more often. Upload directly to object storage using pre-signed URLs, compress images on the device first and resume interrupted uploads instead of starting again. Only mark a record fully synced when both its data and its files have reached the server.
Background sync and connectivity
Do not trust connectivity flags. A device can report a connection that does not actually reach your server, such as captive Wi-Fi or a weak cellular signal. Instead, attempt sync, handle failures and retry with exponential backoff. Trigger sync when the app opens, after local changes and periodically in the background, using WorkManager on Android and background tasks on iOS, which run at times the operating system chooses.
Storage, security and schema changes
Offline data lives on a device that can be lost. Store only what each user needs, encrypt sensitive local databases, for example with SQLCipher, and keep keys in the Android Keystore or iOS Keychain. Clear local data on sign-out.
Plan for schema migrations on the device. Users update the app at different times, so the local database must migrate safely from any older version, and the server must accept sync operations from older app versions for a while. Version your sync API and test upgrades from several past releases.
Testing offline behavior
Offline bugs hide in transitions. Test with airplane mode toggled mid-operation, slow and lossy network profiles, two devices editing the same record, very old local data and devices with wrong clocks. Automate the conflict scenarios at the sync layer, since they are hard to reproduce by hand.
Nexzem builds offline-capable apps for field teams and rural users; our mobile app development page describes how we approach sync-heavy projects.


