Offline-first: designing for the network you do not have
Offline support added to a finished application is a rewrite. The reason is that connectivity is not a feature that sits beside the others — it determines where the truth lives, and that decision shapes every screen, every write and every piece of state in the product.
Part ofMobile App Development
Mobile networks are not a binary. The difficult state is not "offline", which is at least detectable — it is the lift with one bar, the train entering a tunnel mid-request, the hotel network that resolves DNS and drops everything after. An application that only handles the clean cases fails in exactly the situations people remember.
Four levels of support, chosen deliberately
Most applications should be at level two and are at level one. Level four is genuinely expensive and worth it for field work, logistics, healthcare, retail floor staff and anything used somewhere with unreliable coverage — where the alternative is not a worse experience but an unusable product.
Offline-first means the local store is the truth
The architectural inversion is the whole idea: the user interface reads and writes locally, always, and synchronisation happens separately in the background. Nothing in the interface waits for a server.
- Every write goes to the local store first and is reflected immediately
- Changes are queued with enough context to be replayed, including the client's timestamp and a client-generated identifier
- Sync is a background process that can fail, retry and resume without affecting what the user is doing
- Identifiers are generated on the device, so a record exists and is referenceable before the server has ever seen it
The sync queue, which is where the bugs live
A queue of pending changes sounds simple and contains most of the difficulty. Each property below is one that gets discovered the hard way if it was not designed in.
- Durable. The queue survives the app being killed, the device restarting and the battery dying — it lives in persistent storage, not memory.
- Ordered per entity. Changes to one record apply in the order they were made; unrelated records need not block each other.
- Idempotent on the server. A change may be sent twice when a response is lost, so each carries a client-generated key the server deduplicates on.
- Bounded. A queue that grows without limit on a device offline for a fortnight eventually fills the storage — decide what happens then, deliberately.
- Inspectable. The user can see that something is pending, and support can see what is stuck.
- Poison-resistant. One change the server permanently rejects must not block every change behind it forever.
The last is the failure that produces the worst support cases. A single malformed or now-invalid change at the head of a strict queue silently stops all synchronisation, and the user's subsequent work accumulates unsent while everything appears to be working.
Conflicts, which you must decide about explicitly
Two devices edit the same record while offline. Both sync. Something has to give, and the default — whichever arrives last wins — silently discards someone's work.
Tell the user what state things are in
The interface must never imply something is saved to the server when it is queued on the device. That single dishonesty is what turns a sync delay into lost trust when the phone is later wiped or reinstalled.
- A per-item state that distinguishes saved locally, syncing, synced and failed — not a single global spinner
- The age of displayed data, shown plainly, so a stale price or stock figure is understood as stale
- A visible, actionable path when something fails permanently, rather than an indefinite retry with no explanation
- An explicit warning before any action that would discard unsynced work, including sign-out
- Language that says what happened: "saved on this device, will sync when connected", not "saved"
Test the states that actually occur
- Airplane mode for a full session: create, edit, delete, then reconnect and verify everything arrived exactly once.
- A request interrupted mid-flight — the response lost after the server acted. The record must not be duplicated.
- Two devices, same account, conflicting edits offline, then both reconnect.
- A captive portal that returns HTTP 200 with a login page for every request, which defeats naive connectivity checks.
- The app killed with a full queue, then relaunched — nothing may be lost.
- A permanently rejected change in the queue, confirming everything behind it still syncs.
The captive portal case is worth automating. A connectivity check that only asks whether an interface is up will report connected on a hotel network that answers every request with a login page, and the app will retry against nonsense indefinitely.
Questions
Can offline support be added to an existing app?
Rarely without significant rework, because offline is an architecture rather than a feature: it determines whether the local store or the server holds the truth, and that decision shapes every screen, write and piece of state. What can be added incrementally is graceful degradation — showing cached data with its age instead of an error — which delivers much of the perceived quality at a fraction of the cost.
Why do identifiers need to be generated on the device?
Because a record that only receives an id when the server responds cannot be referenced, linked or edited until the network returns — which makes offline creation impossible. Generate a UUID or similar on the device and have the server accept it. Retrofitting this means touching every table and every relationship, so it belongs in the first schema rather than the second.
How should sync conflicts be resolved?
Decide explicitly rather than accepting the default, which is that whichever write arrives last silently discards the other. Field-level merge works where fields are independent; server authority is correct for contended values like stock and balances; asking the user suits documents. Better still, shape the data so conflicts cannot occur — recording "added 3" rather than "quantity is now 8" makes two offline edits combine instead of compete.
What is the most damaging offline bug?
Signing out with unsynced work in the queue. Sign-out typically clears local data, so the app destroys work the user believed was saved, unrecoverably. Close behind is a permanently rejected change at the head of a strict queue, which silently blocks all subsequent synchronisation while the app continues to look like it is working.