Let's talk
engineering

Offline is a contract, not a feature

Field applications get specified with a line item that reads “must work offline”, priced as though it were a checkbox, and then built as an online app with a cache bolted on. It fails in the same way every time: the app appears to work in the basement, the salesperson takes six orders, and at some point the data is gone, duplicated, or silently older than what the office already changed.

Offline capability is not a feature you add. It is a contract about what the application promises when the network is absent, and that contract has to be written before the first screen is designed.

Start by writing the promise down

Three questions, answered explicitly, determine the entire architecture.

What can a user do with no connection? Not “everything”. Recording a visit, capturing photographs, taking an order against a cached catalogue: yes. Checking a live credit limit, confirming a payment, seeing another user’s booking made ten minutes ago: no, and the interface must say so rather than showing stale data as though it were current.

What happens to work done offline? It must survive the app being killed, the phone restarting and the battery dying. That means every offline action is written to durable local storage as it happens, not held in memory awaiting a sync.

Who wins when two people changed the same thing? This is a business question that gets mistaken for a technical one, and answering it wrongly is how field apps quietly lose data.

Local-first writes

The reliable pattern is that the application writes to the local database first and treats that write as the completed action. The user is never waiting on the network to finish their task. A background process then attempts to send local changes to the server and reconcile what comes back.

This makes the local store the primary interface for reading too. Reading from the server when available and locally when not produces two code paths with different behaviour, and the offline one gets tested least. Always read locally; let synchronisation update what is local.

The consequence to accept honestly: what the user sees is as fresh as their last successful sync, and the interface should show when that was. A quiet “last updated 40 minutes ago” prevents more support calls than any amount of hiding it.

The outbox

Pending changes go into an append-only queue with, for each entry, a client-generated identifier, the operation, its payload, a creation timestamp, an attempt count and a status. The identifier is the important part. It is created on the device, travels with the request, and the server uses it to deduplicate.

Without it you get duplicates, and the cause is nearly always the same: the request reached the server, the server processed it, and the response was lost on the way back. The client sees a failure and retries. If the server treats a repeat of the same client identifier as the same operation and returns the original result, the retry is harmless. This is the single most valuable property in the whole design, and it costs a unique column and a check.

Send the queue in order per entity, retry with exponential backoff and jitter, and cap the attempts. When an entry exhausts its retries or is rejected for a reason retrying cannot fix, it needs somewhere visible to go — a “needs attention” list the user can act on. Silently dropping it is how trust in the app dies.

Photographs deserve their own treatment. Compress on the device, store the file locally, queue the upload separately from the record that references it, and let the record sync without waiting on the image. A visit report that cannot be submitted because a 4 MB photo will not upload over a weak connection is a design failure.

Conflict resolution is a business decision

Last-write-wins is the default that gets chosen by not choosing. It is defensible for single-owner data — a field officer’s own visit notes, where nobody else is editing. It is indefensible for anything shared, because it means whoever synced most recently silently erased the other person’s work.

Better options exist and the right one depends on the field. Additive data should merge rather than overwrite: two officers logging separate visits to the same distributor are not in conflict at all, which is an argument for modelling events rather than mutating state. Counters and stock movements should be sent as deltas, so two decrements both apply. Genuinely contested edits to the same field should be surfaced to a human with both versions shown, which is unglamorous but honest.

Server-authoritative fields need saying out loud. Credit limits, prices, approvals and anything financial are decided centrally; the device proposes, the server disposes, and the app must handle its proposal being rejected on sync gracefully — including telling the user that the order they took at a price they quoted has been repriced.

Testing it properly

Airplane mode is not a test. It tests the clean case: fully offline, then fully online. The failures live in between — the connection that is present but delivering nothing, the request that succeeds after the client has given up, the app killed mid-sync, the token that expired during three days offline, the device whose clock is wrong.

Build the ability to simulate those. Introduce artificial latency and packet loss, force duplicate deliveries, kill the process during a sync, run a device offline for a week of test data and then reconnect it. Then test on the actual phones your users have, on the network they have, not on the newest device in the office on wifi.

The contract you wrote at the start is what these tests assert. If nobody wrote it down, there is nothing to test against, and “works offline” remains an aspiration in a requirements document.

Working on something like this?

We build this kind of software, and we staff the teams that do.

Get in touch