Offline-first on mobile
How to design offline-first mobile apps: a local database as the source of truth, sync queues, conflict handling, optimistic UI, iOS and Android background limits, and testing on bad networks.
An offline-first mobile app works fully without signal and syncs when it can. That sounds like a feature you add at the end. It is not. It is a decision about where your data lives, and it changes the architecture from the first screen. We think far more apps should be built this way, because phones spend a surprising amount of time on bad networks: lifts, trains, basements, rural roads, conference Wi-Fi, and every café with a captive portal.
This post covers the approach we use when a mobile app needs to work without a connection, and the trade-offs that come with it. If you are still deciding whether the product should be a phone app at all, start with desktop, mobile or SaaS.
The local database is the source of truth
Most apps treat the server as the truth and the phone as a window onto it. The screen asks the API for data, shows a spinner, and renders the response. Offline, there is nothing to render.
Offline-first flips that. The app reads and writes a database on the device, and the interface only ever talks to that database. A separate sync layer moves changes between the local database and the server in the background. The UI does not know or care whether the phone is online.
In practice the local database is nearly always SQLite, wrapped in whatever suits the platform: GRDB or SwiftData on iOS, Room on Android, or a cross-platform layer if the app shares code. The choice matters less than the rule. Every screen reads from local storage, and every user action writes to local storage first.
Two things follow. The app becomes fast, because a local query takes milliseconds and a network round trip can take seconds. And you have to think about the size of the local data from day one. A notes app can keep everything. A retail app with two million products cannot, so you decide which slice of data each user keeps on the device and how that slice is refreshed.
Sync queues and the outbox
When the user creates or edits something, the app writes the change to its local tables and, in the same transaction, appends an entry to an outbox table. The outbox is a queue of operations waiting to reach the server: "create note 81f2 with this text," "mark task 3a07 done."
A sync worker takes entries from the front of the queue and sends them. A few details make the difference between a queue that works and one that corrupts data slowly:
- Generate IDs on the device. Use UUIDs, not server-assigned integers, so a record created offline has a stable identity before the server ever sees it.
- Make every operation idempotent. Send an idempotency key with each request. If the connection drops after the server applied the change but before the phone got the reply, the retry must not create a duplicate.
- Keep order where it matters. An edit to a note must not arrive before the note exists. Process one record's operations in order, even if unrelated records sync in parallel.
- Back off on failure. Retry with exponential backoff and a cap, and separate "the network is down" from "the server rejected this." A rejected operation needs a human decision, not a thousand retries.
Pulling changes down is the other half. The simplest reliable pattern is a change cursor: the app asks the server for everything changed since the last cursor it saw, applies it locally, and stores the new cursor. Deletions need to be sent as tombstones, otherwise a record deleted on one device lives forever on another.
Conflicts are a product decision
If two devices edit the same record while one of them is offline, you have a conflict. There is no universal answer, and pretending there is one is how data goes missing.
Last write wins is the default for a reason. It is simple and predictable, and for many fields it is fine. If someone renames a project on their phone and then on their laptop, the later name is probably the one they want. Use server-side timestamps or a version counter rather than trusting device clocks, which can be wrong by minutes or years.
Per-field merging is the next step. If one device changed the title and another changed the due date, keep both changes. This needs the sync layer to track which fields were modified, not just which records, but it removes most conflicts that users would notice.
CRDTs (conflict-free replicated data types) handle cases like two people editing the same paragraph of text or adding items to the same list. Libraries such as Automerge and Yjs make them practical. They are also a real commitment in complexity and storage, and we only reach for them when collaborative editing is the core of the product.
For anything involving money, stock or bookings, do not merge automatically. Let the server be the authority, show the user that their change was not accepted, and explain why. Optimism is fine for a to-do list. It is not fine for the last seat on a flight.
Optimistic UI, honestly
Because every action writes locally first, the interface updates instantly. That is optimistic UI, and it is one of the main reasons offline-first apps feel better even when the network is fine.
The honest part is showing state. A small "pending" marker on items that have not reached the server yet, a quiet banner when the app has been offline for a while, and a clear message when something failed to sync. Users do not need to see the queue, but they should never believe something is saved on the server when it is only on the phone. That matters most right before they delete the app or switch phones.
Background sync on iOS and Android
Both platforms restrict what an app can do when it is not on screen, mainly to protect battery. You cannot promise that sync happens at a particular time.
On iOS, BGTaskScheduler lets you request background refresh and longer processing tasks, but the system decides when they run, based on battery, network and how often the user opens your app. A task that is requested is not a task that runs. For large uploads and downloads, a background URLSession hands the transfer to the system so it can continue after your app is suspended. Silent push notifications can nudge an app to fetch, but they are throttled and not guaranteed either.
On Android, WorkManager is the standard tool. You can require conditions like "unmetered network" or "charging," and it survives app restarts and reboots. Periodic work has a minimum interval of 15 minutes, and Doze mode and app standby buckets will defer it further on a phone that is sitting still. Some manufacturers add their own aggressive battery management on top.
The practical design is to sync eagerly whenever the app is open and the network is available, sync on app launch and when returning to the foreground, and treat background sync as a bonus that shortens the wait rather than something the app depends on.
Testing without signal
Offline-first code has more states than online code, and most bugs hide in the transitions. Airplane mode is the start of testing, not the end. We test:
- Bad networks, not just no network. Apple's Network Link Conditioner (in Xcode's additional tools) and the Android emulator's network settings can simulate high latency, packet loss and slow connections. A request that takes forty seconds and then fails is harder on your code than a clean failure.
- Captive portals. The phone reports Wi-Fi, but every request returns a login page. Make sure the sync layer does not parse that HTML as a server response.
- Kills mid-sync. Force-quit the app while the outbox is sending. On relaunch, nothing should be lost or duplicated.
- Two devices, one offline. Edit the same record on both, reconnect, and check the result matches what you designed, not what happened by accident.
- Long absences. A phone that was offline for three weeks, with an old change cursor and a full outbox, and a server that has since changed its schema.
That last case is the one that bites. Version your sync protocol from the first release, keep the server able to accept operations from older app versions, and write a test for the phone that comes back from a long trip with a week of edits. It will happen, usually to your most loyal user.