Skip to content

Apps that work on a bad connection

Warehouses, basements, trains and rural roads: most apps are used somewhere the signal drops. How to design a mobile app that keeps working offline, queues changes safely, syncs without losing or duplicating data, and tells the user honestly what has been saved.

Apps are designed in offices with good Wi-Fi and used in warehouses, basements, delivery vans and on trains. The signal drops, comes back, drops again. An app that treats every lost connection as an error, that loses the form someone just filled in, or that shows a spinner forever, feels broken even when every line of its code is correct.

Designing for a bad connection is not an extra feature to add at the end. It changes where data lives, how changes are sent, and what the user is told. This article goes through the pieces: storing data on the device, queuing changes, syncing them safely, handling conflicts, living with background limits, and testing all of it.

Write locally, sync separately

The core idea of an offline-first app is simple: the app reads from and writes to storage on the device, and a separate part of the app keeps that storage in step with the server whenever it can.

The user’s actions never wait for the network. Saving a note, scanning an item or completing a job writes to the device immediately and shows as done. Sending it to the server happens afterwards, in the background, and can be retried as many times as it takes.

That single change removes most of what makes apps feel fragile. It also creates new questions, which the rest of this article is about.

What to store on the device

What to keep on the device depends on what the user needs when there is no signal:

  • Data the user must be able to read offline: today’s jobs, the product catalogue, the customer they are visiting. Keep a copy, and refresh it when online.
  • Data the user creates offline: forms, scans, photos, status changes. Keep it until the server has confirmed it.
  • Small settings and state: key-value storage is enough.
  • Structured data that is searched and filtered: a real database on the device, such as SQLite.

Sensitive data deserves care: tokens and secrets belong in the platform’s secure storage, and anything personal kept on the device should be no more than the user actually needs there.

An outbox of pending changes

Every change the user makes goes into a queue on the device, an outbox, in the order it was made. A sync process works through the outbox whenever there is a connection: it sends the oldest item, waits for the server to confirm, removes it from the queue, and moves on.

outbox item
  id          a unique id, created on the device
  action      "complete_job", "add_photo", "update_address"
  payload     the data
  created_at  when the user did it
  attempts    how many times sending has been tried

Two rules make the outbox safe:

  • An item leaves the queue only when the server has confirmed it, never when it was merely sent. A request can reach the server and the answer can be lost on the way back.
  • Retries back off: a second attempt after a few seconds, then longer, then longer again, so a phone in a dead zone does not drain its battery trying.

Sending the same change twice, safely

Because a request can arrive at the server while its answer is lost, the same change will sometimes be sent twice. If the server treats the second arrival as a new change, the job is completed twice, the payment recorded twice, the photo attached twice.

The fix is to make every change idempotent: each outbox item carries its unique id, created on the device, and the server remembers which ids it has already applied. A repeat is recognised and answered with the original result, without applying it again. This is the single most important detail in sync, and the one most often missing.

When two changes meet

While one phone is offline, someone else may change the same record. When the phone comes back, the two changes meet. There are three common ways to decide what wins:

  • Last write wins: the most recent change replaces the older one. Simple, and fine for data where only one person realistically edits a record.
  • The server decides: some fields can only be changed through the server’s own rules. A stock level, for example, is the result of all the movements, not whatever number the last phone sent.
  • Merge, or ask: for records several people really do edit, keep both changes where they touch different fields, and ask a person when they touch the same one.

The right choice is different for different data in the same app, and it is a decision to make deliberately, record by record, before the first conflict happens in front of a customer.

What happens in the background

It is tempting to assume the app can sync whenever it likes. It cannot. Both iOS and Android limit what an app may do when it is not on screen, to save battery, and the limits differ between the platforms and between versions.

In practice: sync whenever the app is opened or comes back to the foreground, sync when the connection returns while the app is open, and use the platform’s background task mechanisms for what must continue while the app is closed, knowing that the system decides when those run. An app that needs to keep working in the background, such as one that scans for Bluetooth devices, has to be designed around these rules from the start. I have built one, and the background rules shaped more of it than any other single requirement.

Telling the user the truth

An offline-first app has to be honest about what has been saved where. A user who completes ten jobs in a basement needs to know that they are saved on the phone and waiting to be sent, not wonder whether they were lost.

  • Show a quiet indicator when there are unsent changes, and how many.
  • Show when the last successful sync happened.
  • Show clearly when something failed and will not succeed on its own, such as a change the server rejected, and what the user can do about it.
  • Never show a change as confirmed by the server before it has been.

How to test it

Offline behaviour is only as good as its testing, and it cannot be tested on office Wi-Fi. Useful tools and habits:

  • Airplane mode in the middle of a flow: fill a form, go offline, save, come back online.
  • Network throttling in the simulators and on real devices, to simulate a slow, lossy connection rather than a clean on/off.
  • Killing the app while changes are waiting, and checking they are still there when it opens again.
  • Two devices editing the same record, one of them offline.
  • A walk through the real place the app is used, with the real phones.

The last one finds things none of the others do.

an illustrative example

A worked example: a field service app

Picture an app for technicians who visit customers and record their work: the job, the parts used, a few photos and the customer’s signature. Many visits are in basements and plant rooms with no signal.

  • At the start of the day, the app downloads the day’s jobs, the customers’ details and the parts list, while the technician still has a connection.
  • During each visit, everything the technician records goes into the local database and the outbox, and the job shows as completed on the phone at once.
  • Photos are saved on the phone and queued separately, because they are large.
  • When the phone finds a signal, in the van between visits, the outbox is sent in order, and the photos follow.
  • The office sees each job arrive as it syncs, with the time it was actually done, not the time it arrived.

That last detail is easy to get wrong, and it matters for everything from payroll to customer disputes.

An API built for sync

Offline-first apps need a server built for them. Two things in particular:

  • An endpoint that returns "everything that changed since X", so the app can refresh its copy without downloading everything again. X is best a server-side cursor or change number rather than the phone’s clock.
  • A way to report deletions. If a job is cancelled at the office, the phone needs to be told, so the server keeps a record of what was deleted, often called a tombstone, long enough for every device to hear about it.
GET /sync/jobs?since=8812

{
  "changes": [ { "id": "j-104", "status": "scheduled", ... } ],
  "deleted": [ "j-099" ],
  "cursor": 8840
}

The phone stores the returned cursor and asks for changes since that the next time.

Photos and large files

Photos, signatures and documents need separate handling from ordinary records. They are large, they fail to upload more often, and a failure should not hold up the rest of the outbox.

  • Save the file on the device first, and link the record to the local file.
  • Queue the upload separately, with its own retries.
  • Upload large files in parts where the server supports it, so a dropped connection resumes rather than restarting.
  • Resize photos on the device before sending them. A photo of a meter reading does not need twelve megapixels.

Clocks you cannot trust

Phone clocks are wrong more often than people expect: set by hand, in the wrong time zone, or simply drifting. An app that decides what happened first by comparing the phones’ clocks will sooner or later decide wrongly.

Keep two times for every change: when the user did it, according to the phone, which is what people see, and when the server received it, which is what the server uses to order changes. And never let a phone’s clock decide something that matters, such as which of two edits wins.

Keeping offline data safe

Data kept on the phone travels with the phone, and phones get lost. For business data, a few habits are worth having:

  • Keep on the device only what the user needs offline, and remove it when it is no longer needed.
  • Use the platform’s encrypted storage for tokens and anything sensitive.
  • When a user logs out, remove their data from the device, after making sure nothing unsent is lost, or warning them if it would be.
  • Be able to revoke a lost device’s access from the server.

When the app’s own data changes shape

The app’s local database has a structure, and that structure changes as the app evolves. Unlike a server database, it lives on thousands of phones, each of which updates the app on its own schedule, some of them after skipping several versions.

Every change to the local structure therefore needs a migration that works from any older version, and the outbox needs special care: changes queued by an older version of the app must still be understood by the server after an upgrade. Keeping the outbox format simple and versioned is what makes that possible.

How much offline does your app need?

Not every app needs to be fully offline-first, and building it that way costs more. It is worth being deliberate:

  • An app used mainly at a desk or at home, where the connection is good, can simply handle a dropped connection gracefully: keep what the user typed, retry, and say what happened.
  • An app used in the field, in warehouses, vehicles or buildings with poor signal, needs the full approach: local data, an outbox and sync.
  • Many apps are in between: most screens are fine online-only, and one or two workflows, the ones done on site, need to work offline.

Designing those one or two workflows properly is usually far better value than making the whole app offline-first.

Graceful, even when not offline-first

Even an app that is mostly online should treat the network as unreliable:

  • Never lose what the user typed. Keep a draft of any form until it has been submitted successfully.
  • Show the difference between "saving", "saved" and "failed", and let the user retry a failure.
  • Time out requests after a reasonable wait, and say so, rather than spinning forever.
  • Make repeated submissions safe on the server, with the same idempotency idea as the outbox, so a user who taps "send" twice on a slow connection does not create two orders.

These cost little, and they are the difference between an app that feels solid on a train and one that feels broken.

Need an app that works where the signal does not?

Tell me where it will be used and what it has to record.

Discuss your project