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.

A hand holding a phone beside a train window in a dark tunnel

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.

  1. 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.
  2. On the Android emulator, open Extended controls and lower the cellular network type and signal strength.
  3. Kill the app while the outbox has pending operations, reopen it offline, then reconnect. Nothing may be lost or sent twice.
  4. Edit the same record on two devices while one is offline, then bring both online.
  5. Switch from Wi-Fi to cellular in the middle of an upload.
  6. Join a network that sits behind a login page and check that the app does not report success.
  7. 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