Engineering
Why Field Apps Fail: Building Offline-First Mobile Software
An app demonstrated on office wifi and used at a site gate on one bar of 4G is two different pieces of software. Only one of them was tested.
The pattern is consistent enough to predict. A field app is commissioned, built competently, demonstrated successfully and rolled out. Within two months the field team is back on paper, and the reason given is that 'it kept losing data'. Usually it did not lose data - it failed to receive it, then said nothing, and the difference is invisible to the person standing at the gate.
Once field staff stop trusting an app, they stop using it, and no amount of feature work brings them back. Trust is the actual product, and it is decided by what the app does on a bad connection.
Why connectivity assumptions break
Office wifi is not a weak version of a site connection - it is a different thing. A site has intermittent coverage rather than slow coverage: full signal by the office, nothing behind the structure, and a captive portal in the client's building that returns HTTP 200 for every request with a login page in the body.
That last case is worth dwelling on, because it defeats naive connectivity checks. The device reports a network. A request succeeds. The response is not what was asked for. An app that tests 'is there a connection' rather than 'did the server actually answer' will happily believe it has saved something it has not.
The other assumption that breaks is duration. An app that expects to sync within minutes behaves differently from one that must handle a device offline for three days, during which the server's data has moved on. The second is the normal case in field work and it changes the design.
Offline-first means the local store is the truth
The distinction is not 'we cache some things'. In an offline-first design the local database is where the user's work lives, writes complete locally and return immediately, and synchronisation is a background process that reconciles with the server later. The network is an enhancement, not a precondition.
This inverts the usual error handling. In an online-first app, no network is an error the user must deal with. In an offline-first app, no network is a normal state and the interface reflects that - the record is saved, it is queued, it will go when it can, and the user can see that is true.
Choose the conflict model deliberately
Two people edit the same record while both are offline. Whatever happens next is your conflict model, and if nobody chose one then the code chose it by accident - usually last-write-wins, silently, with one person's work disappearing.
There are only a few workable options, and the right one depends on the data.
- Last-write-wins is acceptable for genuinely independent fields and for data where being slightly stale is harmless. It is the wrong choice for anything financial or anything a dispute could attach to.
- Append-only event logs avoid conflicts entirely by never editing in place. Attendance marks, material issues, stock movements and inspection records all fit this naturally - two supervisors marking attendance are adding facts, not competing to overwrite one.
- Field-level merge works when a record has sections owned by different roles, so two edits usually do not touch the same fields.
- Explicit resolution - showing both versions and asking - is correct where the data matters enough to be worth someone's attention, and intolerable if it happens often. If your model needs it frequently, the model is wrong.
In field operations, append-only fits far more cases than teams expect, and it removes the hardest class of bug by construction. It is worth restructuring the data to get there.
The queue is the part that has to be bulletproof
Everything a user does offline goes into a queue, and every serious failure mode lives there. The queue must survive the app being killed by the operating system, the phone restarting, and the app being updated to a version whose data shape has changed.
Three rules make it reliable. Each queued operation carries a client-generated identifier so the server can recognise a duplicate - retries are certain, and without idempotency they create duplicate records. Operations apply in order where order matters, because a create that arrives after its own update is a data-loss bug. And a permanently failing item must be quarantined and surfaced rather than retried forever, or one bad record blocks every good one behind it.
That last one is the most common production failure. A record the server rejects for a validation reason sits at the head of the queue, retrying, while everything behind it waits. The phone shows 'syncing'. Nothing has synced for two days.
Make sync state visible
Users forgive an app that cannot reach the server. They do not forgive an app that cannot tell them whether it has. Every record should show whether it is saved locally, queued, synced or failed, and there should be one screen that says when the last successful sync happened and how many items are waiting.
This also transforms support. 'It did not save' is unanswerable. 'Three items queued, last sync 2 days ago, one failed on a validation error' is a question with an answer, and the answer can be given over the phone.
Background work that actually runs
Android's battery optimisation will stop naive background sync, and vendor skins are more aggressive than stock Android - some of them by a wide margin. An app that syncs reliably on a Pixel may not sync at all on a phone whose manufacturer kills background work aggressively, and those phones are common in exactly the field-staff price range.
Use the platform's own scheduler rather than a timer, sync opportunistically when the app is foregrounded, and never rely solely on background work for something the user needs to have happened. Test on the cheap phones your users carry, not on the developer's device.
Test on the network they have
- Aeroplane mode for a full working day, then reconnect and verify everything arrives exactly once.
- A throttled, lossy connection rather than a merely slow one - the failure modes are different, and loss is what breaks things.
- A captive portal that returns 200 with a login page, to confirm the app checks the response rather than the connection.
- Kill the app mid-sync, restart the phone mid-sync, and confirm the queue survives both.
- Two devices editing the same record offline, to confirm the conflict model does what you decided rather than what the framework defaults to.
- An old app version syncing to a current server, because in field deployments someone is always three versions behind.
The summary
Offline is not a feature to add near the end. It determines the data model, the conflict rules, the queue design and the interface, and retrofitting it is close to a rewrite. Decide it first, make the local store authoritative, choose the conflict model explicitly, make sync state visible, and test on a bad connection rather than a good one.