Offline Mode in a Mobile App: What Breaks When the Network Disappears
The most common way I’ve seen offline mode “tested”: turn on airplane mode, open the app, see a “No connection” placeholder, close the ticket. There’s a placeholder, so offline works.
But users don’t lose the network on the home screen. They lose it mid-action. They tapped “Save” in an elevator, liked a post on the subway, finished a note on a plane and killed the app while waiting to board. Offline bugs don’t live in what the app shows without a network. They live in what happens to the user’s actions while there’s no network, and at the moment it comes back.
Earlier we covered a bad network: slow, flaky, dropping packets. Today it’s the complete absence of one.
First, agree on what should work at all
Offline isn’t one requirement, it’s a table. For every screen and action, decide up front which of three it is:
- Works fully. Reading downloaded content, drafts, local settings, playing a level in a game.
- Works with deferred sending. A like, a comment, a save, “mark as read” — the action is accepted now and sent to the server later.
- Honestly unavailable. Payment, first sign-up, server-side search. These need a clear screen, not an endless spinner.
If that table doesn’t exist, your first job isn’t testing, it’s asking. Half of offline bugs are really spec bugs: the developer decided a like with no network can just be dropped, and product assumed otherwise.
The main anti-pattern in the third group is the endless loader. The request hangs until the 60-second system timeout, the user stares at a spinner and leaves. Good behavior: notice quickly that there’s no network, say so in words, and keep navigation alive.
The action queue: lose nothing, duplicate nothing
If an action is “accepted now, sent later”, the app has a queue. And queues have two classic bugs: loss and duplication.
Loss. An action was done offline and after reconnecting it isn’t on the server. The check is simple: turn on airplane mode, do 3–5 different actions, bring the network back, and open the data from another device or the web. Everything must arrive.
Duplicate. The action was sent and the server executed it, but the response never arrived — the network dropped at exactly that moment. The client thinks sending failed and retries. Result: two comments, two orders, currency deducted twice. To reproduce, set a breakpoint in a proxy (Proxyman, Charles), let the request reach the server and cut the response. The defense is idempotency: the client sends an operation key and the server won’t run the operation twice. There was a whole post on retries.
Three more queue checks that almost always find something:
- Order. Create a note, edit it, delete it — all offline. After reconnecting the operations must go out in the same order. If “delete” arrives before “create”, the server returns an error and the note comes back to life.
- Cancellation. Like a post offline and immediately unlike it. The right result is to send nothing, or the final state — not two events in random order.
- A failure while sending. The server rejects one queued action, say the post was deleted. The rest must still go through, and the user must learn that one didn’t. A stuck queue that blocks everything after one error is a common, nasty bug.
Conflicts: edited in two places
A user edited a note on their phone on a plane while also changing it on their tablet at home. The phone lands, the network is back — whose version wins?
The app has few options: the last write wins, fields are merged, or the user is shown both versions. None is “right”, but the chosen one must be documented and behave consistently. What to check:
- two devices, one entity, offline edits on both, then reconnect one after the other and at the same time;
- edits to different fields (title on one, body on the other) — is one of them lost;
- delete on one device and edit on the other — does the deleted item come back;
- the device clock set backwards: if conflicts are resolved by client time, the old edit becomes the “latest”.
The last one is especially sneaky: comparing by device time breaks as soon as the user travels to another time zone or just has a wrong clock.
Killed while offline
The most realistic scenario of all: no network, the user does something and swipes the app away. Or the system unloads it from memory while they answer a call.
Checks:
- the queue of unsent actions survives the process being killed — it must live on disk, not in memory;
- on the next launch, now with a network, the queue goes out by itself, without opening the relevant screen;
- reading progress, a draft, a video position — still there, not rolled back to the last sync;
- the first launch after install with no network doesn’t crash or hang on a white screen.
The easiest way to kill it: adb shell am force-stop <package> — closer to the system unloading the app than a swipe, and it doesn’t depend on the launcher.
An honest cache
Showing the last loaded data offline is good. Showing it as if it were fresh is bad. A three-day-old feed with no hint of it looks like “nothing new is happening”.
What to check:
- it’s visible that the data is stale: an “updated yesterday” label, a “no network” banner;
- after the network returns, data refreshes by itself, without pull-to-refresh;
- the cache doesn’t go stale forever: after a long time offline and a reconnect, no old images and prices remain;
- prices, stock levels, order statuses — where stale data does harm — are marked especially clearly or hidden.
Transitions, not states
Most bugs are neither “offline” nor “online” but in the switch between them. A minimum set:
- airplane mode turns on mid-request — the loading indicator must turn into an error, not freeze;
- Wi-Fi without internet (router yes, provider no) — the app often thinks the network is there because the system shows the Wi-Fi icon;
- a captive portal in a café or hotel — requests get the login page HTML instead of JSON;
- the network returns while the app is in the background — the queue must go out when the user comes back, not only after a restart;
- a flickering network: on, off, on within a couple of seconds — no duplicates and no stuck states.
Tools
- Airplane mode — basic and honest, but it turns off everything at once.
- Android:
adb shell svc wifi disableandadb shell svc data disable— turn Wi-Fi and mobile data off separately and fit nicely into scripts. - iOS: Network Link Conditioner in developer settings, the 100% Loss profile — the network “exists” but nothing gets through. Good for simulating Wi-Fi without internet.
- A proxy (Proxyman, Charles): block individual hosts and set a breakpoint on the response. This is the main tool for duplicates: the request got through, the response didn’t.
- A second device or the web version — to see what actually reached the server instead of trusting the screen.
Checklist
- There’s a table: what works offline, what’s deferred, what’s unavailable.
- Unavailable screens show a clear message quickly with no network, no endless spinner.
- All actions done offline reach the server after reconnecting.
- A cut response doesn’t create a duplicate on the server.
- The order of offline operations is preserved.
- A cancelled offline action isn’t sent, or is sent as its final state.
- One failed queued action doesn’t block the others, and the user is told about it.
- Conflicting edits from two devices follow the documented rule; deleted items don’t come back.
- The queue and drafts survive force-stop, and the queue goes out by itself on the next launch.
- The first launch with no network doesn’t crash or hang.
- A stale cache is marked, and data refreshes by itself when the network returns.
- Wi-Fi without internet, captive portals and a flickering network don’t break the app.
Offline is rarely tested end to end because the office always has a network. Users regularly don’t. An hour with airplane mode, a proxy and two phones finds the bugs that otherwise show up in reviews saying “everything is gone”.