Designing for the disconnected user: offline-first apps that stay honest
How to build mobile apps that keep working offline: offline-first architecture, local storage, sync and conflicts, honest UI states and bad-network tests.

Updated 15 Oct 2026
Somewhere between two stations, in a parking garage, or in the back corner of a warehouse, your app loses the network. What happens next decides whether people trust it. A spinner that never ends, a form that empties itself, a "Something went wrong" with no way forward: each of those teaches users that the app only works on good days.
Disconnection is rarely a clean switch. More often the phone shows full bars while packets vanish, a hotel network waits behind a login page, or the connection drops halfway through an upload. Designing for the disconnected user means treating all of those as normal conditions, not as errors. This guide covers the architecture, local storage, sync and conflicts, the UI states that keep the app honest, and how to test it all without waiting for a train.
Contains affiliate links. As an Amazon Associate I earn from qualifying purchases.
Offline-first in one sentence
The app reads from a local store, writes to the local store, and a separate sync process moves changes between that store and the server. The screen never waits for the network to show something; it shows what is on the device and updates when the store changes.
Android's guide to building an offline-first app puts it the same way: the local data source is the canonical source of truth, and higher layers read only from it. The same shape works on iOS with a local database observed by SwiftUI views. The rule that matters on both is that no screen talks to the API directly.
Decide what works offline, feature by feature
Not everything should work offline, and pretending otherwise is worse than saying no. Go through the app's features one by one and give each a write strategy. The Android guide names three, and they apply to any platform.
| Strategy | What happens | Good for | Watch out for |
|---|---|---|---|
| Online only | The write goes to the server, then to local storage | Payments, sign-up, booking a limited slot | Say clearly that it needs a connection |
| Queued | The change joins a queue that drains when online | Analytics, likes, read receipts | Silent failures nobody notices |
| Lazy | Local write first, sync at the next chance | Notes, forms, checklists, field reports | Conflicts with changes from elsewhere |
A field inspection app might be lazy for every report, queued for photos, and online only for submitting the final signed version. Writing that list down early saves weeks, because it is the list the backend team needs too.
Local storage that survives
On Android, Room is the usual choice for structured data and DataStore for small settings. On iOS, SwiftData or Core Data for records, files for large blobs such as photos, and UserDefaults only for small preferences.
Where you put files matters on iOS. The system can clear the Caches directory when the device runs low on space, so anything the user created belongs in Application Support or Documents, never in Caches. On both platforms, decide how much to keep: the last 30 days of orders, the current project, the next week of shifts. An app that tries to mirror the whole server will be slow on the phones that most need it.
Sync, retries and conflicts
Model every local change as an operation in an outbox table: what changed, which record, a client-generated ID and a status. The sync process sends operations in order, marks them done when the server confirms, and leaves them in place when it does not. The client-generated ID is what makes retries safe. If the server has seen that ID before, it returns the earlier result instead of creating a second order.
On Android, WorkManager runs the outbox with a network constraint and exponential backoff, and it keeps the work scheduled across app restarts and reboots.
val sync = OneTimeWorkRequestBuilder<OutboxWorker>()
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build()
WorkManager.getInstance(context)
.enqueueUniqueWork("outbox", ExistingWorkPolicy.APPEND_OR_REPLACE, sync)
On iOS, a URLSession configured with waitsForConnectivity waits for a connection instead of failing at once, and background sessions always wait. For periodic refreshes, the BackgroundTasks framework lets you request time, but the system decides when it comes, so never build a feature that depends on it running on schedule.
Conflicts come next. The simplest rule is last write wins, which the Android guide uses: each write carries a timestamp and the newer one survives. It is fine for data one person edits, such as their own settings. For shared records it loses work quietly, and device clocks are often wrong, so prefer a version number assigned by the server. When the app sends a change based on version 7 and the server is already at version 8, the server rejects it and the app can merge field by field or ask the user. Collaborative text editing is its own problem, usually solved with specialized data structures; don't invent one under deadline.
Honest UI states
Users forgive a missing connection. They don't forgive an app that lies about what it saved. Every record that can be changed offline needs a visible state, and the wording should be calm and specific.
- Saved on this phone: written locally, not yet sent
- Waiting to sync: queued, with no action needed
- Syncing: in progress, and still editable if possible
- Synced: shown sparingly, or not at all, since it is the normal state
- Couldn't sync: with the reason and one clear action, such as retry or review changes
Show the age of data when it matters ("Updated 3 hours ago") rather than hiding cached data behind an error. Keep reading possible while offline and disable only the actions that truly need the network, with a short line explaining why. An empty screen deserves the same care, since a first launch without a connection is one of the hardest empty states there is; the guide to onboarding flows people actually finish covers empty states that teach rather than apologize.
A connectivity check before each request is a weak gate. The check can pass and the request still fail, or the reverse. Use path monitoring (NWPathMonitor on iOS, a network callback on Android) to update the UI and to trigger a sync, but always attempt the real request and handle the failure.
Testing with bad networks
Airplane mode tests one easy case. The hard cases are slow, lossy and changing connections, so test those deliberately.
- Use the Network Link Conditioner on an iOS development device (Settings, Developer) with a lossy or very slow profile, or set device conditions from Xcode's Devices window.
- On the Android emulator, open Extended controls and lower the cellular network type and signal strength.
- Kill the app while the outbox has pending operations, reopen it offline, then reconnect. Nothing may be lost or sent twice.
- Edit the same record on two devices while one is offline, then bring both online.
- Switch from Wi-Fi to cellular in the middle of an upload.
- Join a network that sits behind a login page and check that the app does not report success.
- Walk the route your users walk with a real phone: the elevator, the basement, the far end of the building. A small shelf of test phones, as in the guide to an Android test device lab, makes this a routine instead of an event.
Apps that talk to hardware have the same problem twice, once with the network and once with the device itself; the guide to companion apps for connected devices picks up from there.
Questions
Does every app need to work offline?
No, but every app needs to behave well when offline. A banking transfer can refuse to run without a connection, as long as it says so clearly, keeps what the user typed, and shows the last known balance with its date.
Is last write wins good enough?
For data that only one person edits on one device at a time, usually yes. For anything shared between people or devices, use server versions and decide per field how to merge, or you will lose edits without anyone noticing.
How much data should an app keep on the device?
Enough for the next realistic stretch without a connection, and no more. Base it on how people use the app: a day of deliveries, a week of shifts, the current trip. Add an expiry rule so old data leaves the device on its own.
How do we show unsynced changes without alarming people?
Use a small, neutral marker on the item and a single summary line somewhere stable, such as "2 changes waiting to sync". Save red and warnings for real failures that need the user to act.
More on mobile development

A powered USB hub for test phones: power, ports and switches
How to choose a powered USB hub for test phones: why bus-powered hubs drop devices, what to read on the spec sheet, and why a switch on every port helps.

Building a small Android test device lab on a budget
How to pick Android test phones by role, buy them refurbished, keep them charged and connected over Wi-Fi, and decide which to update and which to freeze.

Snapshot testing for iOS and Android: a field guide
How snapshot tests work on iOS and Android, which libraries to use, how to record and verify, where flaky diffs come from and what is not worth snapshotting.