The four ways offline apps fail in the field
Four failure modes: a frozen interface, a silent overwrite, a conflict found weeks later, and a request that stalls instead of failing. With the test for each.
Field apps fail offline in four ways: the interface hangs waiting for a server that is not there, a sync quietly overwrites work someone else did, a conflict sits unnoticed until an audit weeks later, and a request stalls without ever failing, so nothing falls back to the local cache. Only the first announces itself.
A field app fails offline when it treats the absence of a network as an error instead of a normal operating state. The four below are all versions of that mistake. What makes three of them expensive is that nothing goes red at the time.
The interface freezes over data the device already has
The screen has the records. It renders a skeleton anyway.
This happens when components fetch on mount and treat the request as the thing that produces the content, with the local cache as a consolation prize handed out after the request fails. With no signal the request does not fail quickly, so the consolation prize never arrives, and the engineer looks at grey rectangles while standing next to the asset they came to inspect.
The fix is an ordering change, not a new library. Read from the local database first, synchronously, on mount. Paint. Then revalidate behind whatever is already on screen. A screen that holds data should not have a loading state at all, online or off.
The test is cheap. Stub the network layer with a promise that never settles, mount every screen against it, assert each one renders its content anyway. Any screen still showing a skeleton there will show a skeleton in a basement. That single test finds more offline bugs than an afternoon of manual airplane-mode clicking, because airplane mode rejects immediately, and rejecting immediately is not what a basement does.
A sync that quietly overwrites someone else's work
An engineer edits a job in a plant room. The office edits the same job an hour later. The engineer comes back into signal and syncs, the office's changes are gone, and nothing anywhere reports an error, because from the server's point of view a client sent a complete record and the server stored it.
That is last write wins, and it is what you get when nobody decides. It is not always wrong. On records with a single realistic writer it is cheap and fine. It gets expensive the moment two roles touch the same record, which in field work is nearly always, because the office schedules and the engineer executes. We have written up the trade-offs between last write wins, field-level merge and CRDTs separately.
How it gets caught: a scripted two-device test, with the assertion made per field, not per record. Device A goes offline and edits one field. Device B, online, edits a different field on the same record. A comes back. Both edits have to survive. If your write path sends the whole document, that test fails on its first run and tells you something useful about your API, not your client.
The reason this one survives so long in the wild is that nobody sees it. There is no error state and no retry banner. There is a record that used to say one thing and now says another, and the person who notices is the person whose typing disappeared, three days later, if they remember typing it.
The conflict that surfaces weeks later
Some conflicts do not resolve wrongly at sync time. They resolve into a record that reads plausibly and only turns out to be wrong when someone reconstructs the day. In regulated work that reconstruction is not hypothetical. The record is the deliverable, and eventually somebody asks what it looked like on a particular date.
The mechanics are usually clock-shaped. An event captured on a device at 09:40 reaches the server at 16:10. Another event, captured later on a different device, arrives first. If your ordering uses arrival time, the sequence in the record is not the sequence that happened. If it uses device time, an engineer with a wrong clock rewrites history. We store both and never derive one from the other, so we can still answer the question on the days the two disagree.
Catching it means splitting captured-at from received-at in the first commit, then writing a report that lists records where the two differ by more than a shift, or where arrival order contradicts capture order. Run it in staging against a device with a deliberately skewed clock. The test surface is the audit trail, not the current state of the record, which is why proving when an offline capture happened is worth designing before you need it.
This failure costs the most to repair, because repair means a human reading two versions of a record and deciding which is true, weeks after both authors have forgotten writing either.
The stalled fetch, or offline that has not admitted it yet
We hit this one in a demo, in front of people, on venue wifi.
The app was the engineer client for a field operations platform in a regulated compliance industry. The device had a fully warmed local cache holding every record the demo needed. The screens showed skeleton loaders. The venue wifi was the kind that associates, hands out an address, and then passes almost nothing.
Our read path was network-first with a fallback to cache on rejection. The requests did not reject. They stalled. A fetch with nobody answering at the other end will sit there for a very long time on its own, and the browser reported the device as online throughout, because it was online: it had an address and a gateway that never replied.
Stalling is the normal field failure. Dropping cleanly is the rare one. What changed after that morning:
- Deadlines by request class. Ten seconds for an interactive read, forty-five for a background read, forty-five for a background send, five minutes for a photo upload. Past its deadline a request is converted into a network error, which is the thing that makes a cache fallback fire.
- Reads paint from the local store on mount and revalidate behind, so a bad network degrades the freshness of a screen instead of its existence.
- A 502, a 408 and a 429 all count as offline. A dead radio and a broken gateway are the same event to an engineer standing in a plant room, and treating them differently only produces two ways to get stuck.
- A guard for captive portals: a 200 response whose body is not a JSON object throws. Several of our callers replace cached data wholesale, so a hotel login page returning HTML under a 200 would have wiped real work.
The deadline numbers are judgement, not science. Ten seconds is a long time to watch a spinner and a short time for a bad connection to deliver a page of jobs. We have moved them twice since.
You find this one with a proxy that accepts the connection and then never answers. Ours runs as a local process with a configurable hang, and it is the only thing in our test rig that reproduces venue wifi reliably.
Test for a bad network, not for no network
Every team tests airplane mode. Airplane mode is the kindest thing that ever happens to a field app. The states worth building a rig for are the ones in between: one bar, a proxy that swallows requests, a gateway returning 502 for ninety seconds, a connection that dies halfway through a photo upload and returns during the retry.
| Failure | What the engineer sees | The test that finds it |
|---|---|---|
| Interface waits on the server | Skeletons over data the device already holds | Mount every screen with the network stubbed to a promise that never settles |
| Silent last write wins | Nothing, until a record is wrong later | Two devices, one offline, different fields, assert both edits survive |
| Late-surfacing conflict | A record that contradicts itself at audit | Skewed device clock in staging, plus a report on capture-to-sync gaps |
| Stalled fetch | Spinners on full bars | A proxy that accepts the connection and never replies |
We still test the captive portal case by hand, by walking into a coffee shop with a tablet, because we have not found a way to fake one convincingly enough to trust. Admitting that is slightly embarrassing. The alternative is trusting a mock we wrote ourselves, and a mock we wrote has never once surprised us.
What the four have in common
They all come from treating connectivity as a state the app can ask about, rather than a property of each individual request. The browser's online flag tells you the radio has an address. It does not tell you whether the request you are about to make will arrive, and in a basement or a plant room that is the only question worth asking.
Which is why most of the fixes above are test infrastructure rather than architecture. Teams generally get the shape roughly right and then never exercise the states that matter, so the bugs ship, stay quiet for a month, and turn up in an audit.
If you only have budget for one change this quarter, build the rig before you touch the data layer. A harness that can hold a request open indefinitely, skew a device clock and drive two devices through the same record will tell you which of the four you have, and it will keep telling you every time somebody adds a screen. Ours took about two days to put together and has paid for itself several times since.
Ready to build the thing?
Book a free 30-minute call. We'll dig into your idea, your stack and your timeline, and give you an honest read on what it will take to build and launch. You'll leave with a clearer plan whether or not you hire us.