Building an app that keeps working when the connection drops
Every mobile app works offline for a moment. The question is what it does with that moment.
Most apps treat a dropped connection as an error state: a spinner, a toast saying something went wrong, and a screen that is now useless. That is a reasonable default for a product people open for thirty seconds on a train. It is the wrong default for anything used by someone working on a site, in a vehicle, on a ward, in the air, or anywhere the signal is a courtesy rather than a guarantee.
Offline-first is the other default. The app reads and writes locally, always, and treats the network as a background concern that syncs when it can. The user never waits for a server to answer a question the device could answer itself. This is more work than adding a cache at the end, and it has to be decided at the start, because retrofitting it means rewriting the data layer.
A cache is not a data layer
The most common thing we are asked to fix is an app that was made “offline capable” by caching API responses. It demos well and fails in a specific way: reads work, writes do not. The user can see yesterday’s data and cannot record today’s.
An offline-first app inverts the relationship. The local database is the source of truth for the interface. Every screen reads from it. Every action writes to it and returns immediately. A separate process moves changes to and from the server. The network layer never blocks the interface, and the interface has no concept of a request failing, because it never makes one.
That inversion is the whole architecture. Everything below is a consequence of it.
The queue is the hard part
Once writes happen locally, you need something to carry them to the server later. That queue is where most of the engineering lives, and it has requirements that are easy to state and easy to get wrong.
Every operation has to be safe to repeat
A queued request will sometimes be sent twice: the app sends it, the connection dies before the response arrives, and on reconnect the app cannot tell whether the server received it. If sending it again creates a second record, you have just duplicated a user’s work.
The fix is that the client generates the identifier, not the server. If the device decides that this new record is id a7f3, then sending it twice is harmless — the server recognises the second as a repeat of the first. Idempotency is not an optimisation here. It is the thing that makes the queue trustworthy.
Order matters, and sometimes it does not
If a user creates a record and then edits it, those two operations must reach the server in that order. If they edit two unrelated records, the order is irrelevant and forcing it serialises the sync for no reason. A queue that preserves order globally is simple and slow; one that tracks dependencies is faster and more complex. Which you want depends on the product, and it is worth deciding deliberately rather than inheriting whichever the library does.
A failing operation must not block everything behind it
One malformed write at the head of a strict queue will stop every subsequent change from syncing, indefinitely, while the user carries on working and the backlog grows. The queue needs a way to set a poisoned operation aside, keep going, and surface it to someone rather than retrying it forever in silence.
Conflicts, and who decides
Two people edit the same record while both are offline. Both reconnect. Someone has to lose, or the two changes have to be combined, and the app cannot decide that on its own.
We give the server authority over conflicts. The client proposes, the server rules, and the client accepts the result. The alternative — letting whichever device syncs last overwrite the other — is simpler and produces silent data loss that nobody discovers for weeks.
Within that, the right rule depends on what the field means:
- Last write wins is fine for something only one person realistically touches, like a personal note or a preference.
- Field-level merge works where two people editing different fields of the same record should both succeed. It requires tracking changes per field rather than per record, which is a schema decision made early or not at all.
- Additive-only structures avoid the problem entirely. A log of events that only ever grows cannot conflict, and the current state is computed from the log. More storage, no conflicts.
- Ask the user is the honest answer for anything consequential. If two clinicians recorded different observations, no automatic rule should pick one. Surface both and let a person decide.
The mistake is choosing one rule for the whole application. Different fields deserve different rules, and that mapping belongs in the design document rather than in a developer’s head.
The clock lies
If conflict resolution depends on which change happened first, and “first” comes from the device clock, then a phone with the wrong time quietly wins every argument. Users change their clocks. Timezones shift. Daylight saving moves an hour twice a year.
Use the server’s time as the authority wherever ordering has consequences, keep a monotonic local counter for ordering operations within one device, and treat the device clock as a display convenience rather than a fact.
How long is offline
This is the question we ask earliest, because the answer changes the design more than any other.
An app offline for ten minutes needs a modest queue and little else. One offline for a full working day needs real storage planning, a sync that can reconcile hundreds of operations without locking the interface, and a considered answer to what happens when the backlog is large. One offline for a week needs all of that plus a conversation about what data is even worth holding.
“It should work offline” is not a requirement. “It should work for eight hours with no connection and reconcile in under a minute” is one, and it can be tested.
Telling the user the truth
An offline-first app has states a connected app does not, and hiding them is its own kind of failure.
The user needs to know whether what they are looking at is current or stale, whether what they just saved has reached the server or is still waiting, and whether anything has failed and needs them. Three pieces of information, shown honestly and without alarm. An app that looks identical whether or not it is synced will eventually lose somebody’s work and give them no warning that it was at risk.
Equally, an app that shouts about connectivity constantly teaches people to ignore it. The state should be visible to anyone who looks for it and quiet for everyone who does not.
Testing it properly
Offline behaviour is almost never tested well, because the interesting cases are tedious to produce by hand. The ones worth automating:
- Write while offline, reconnect, confirm the change arrives exactly once.
- Write the same record on two devices while both are offline, reconnect in each order, confirm the result matches the rule you chose.
- Kill the app mid-sync and restart it. The queue has to survive a process death, which means it lives in the database rather than in memory.
- Reconnect on a slow, flapping connection rather than a clean one. Nine seconds on, four minutes off, repeatedly.
- Let the backlog grow large, then reconnect. Watch the interface stay responsive while it drains.
A simulated airplane-mode test tells you very little. The failures live in the transitions.
When not to do this
Offline-first costs real money and we will say so. If your users are always connected, if the data is inherently live — prices, availability, anything another party is changing — or if being slightly out of date is dangerous rather than inconvenient, then a connected app with good error handling is the correct and cheaper answer.
The decision hinges on one question: when the connection drops, is the user’s work interrupted, or is it lost? If it is merely interrupted, handle the error well. If it is lost, build for offline from the beginning.
If you are building something that has to work out there
Tell us where it gets used and for how long it has to survive without a signal. We will tell you what that implies, with a rough estimate, before anyone signs anything.
Related reading
- Mobile App Development — what we build and how
- Backend and API Development — the server side that rules on conflicts