The problem nobody budgets for
Field apps fail in a specific, predictable place: the last mile. A rep captures an order in a basement warehouse, a technician closes a job card in a parking structure, a van driver invoices at a roadside stop. The signal drops. The app either blocks them, or it pretends to save and quietly loses the work.
Both outcomes are unacceptable. If the app blocks, the field team stops working every time coverage dips. If it loses work, trust evaporates — and reps start keeping paper backups of a digital system, which is worse than paper alone.
When we built offline writes into the xMatix mobile app, we started by cataloguing the ways naive designs fail. That catalogue shaped every decision that followed.
How naive offline designs lose data
The in-memory retry queue. The simplest design holds failed requests in memory and retries them. Then the OS kills the app to reclaim memory — which mobile operating systems do constantly — and the queue is gone. The rep saw a success toast. The server never heard a thing.
The response cache masquerading as a database. Many apps cache HTTP responses keyed by the query that produced them. That makes yesterday's list render offline, but you cannot filter it, sort it, or edit a record inside it — it is a screenshot of a moment, not data. The first offline edit has nowhere to live.
The blind retry. A queue that retries everything forever will hammer the server with a write that a business rule will reject every single time. It can never succeed. Meanwhile the writes queued behind it wait, and the user has no idea anything is wrong.
The duplicate create. The request reaches the server, the response is lost in transit, the client retries — and now there are two orders. At scale this is not an edge case; it is a certainty.
The orphaned line item. An order is a header plus lines. Queue them as independent writes and the lines can arrive before the header exists, fail their foreign-key check, and vanish — or worse, half-arrive.
Last-write-wins. The rep edits a record offline. Back at the office, someone edits the same record. Whoever syncs last silently erases the other person's work.
The photo that was never there. The app queues an upload that points at a file in the camera cache. The OS purges the cache under storage pressure. The queued upload now references nothing.
Each failure is survivable alone. Together they describe an app that cannot be trusted, and field teams figure that out within a week.
The design: local first, then a durable outbox
The xMatix answer has two halves: reads come from a local store, and writes go through a durable outbox. Neither half negotiates with the network before doing its job.
Reads: a real local database, not a cache
Every offline-enabled screen reads from a per-record local database first. Records — not cached responses — are stored row by row, scoped by environment and tenant so a shared device can never leak one tenant's data into another's session. Each row carries the server version it was last read at. Delta sync pulls only what changed since the last cursor, and the app can filter, sort, and search locally because it holds actual records with actual indexes.
This matters for writes too: an edit form that opens from local data can open anywhere, which is the precondition for capturing the edit in the first place.
Writes: every mutation is queued, always
Every create, update, delete, and action goes through the outbox — even when the device is online. Online just means the queue drains immediately. This is deliberate: the outbox doubles as the durability and idempotency layer for all saves, so a connectivity drop mid-request is indistinguishable from working offline. There is no separate "offline mode" code path to get wrong.
The outbox lives in the same durable local database as the records. Kill the app, restart the phone, come back tomorrow — the queue is still there.
Idempotency: two identities, not one
Exactly-once delivery over an unreliable network requires the server's help. Each queued write carries two identifiers, and the distinction is the whole trick.
- A stable document identity, minted when the user creates the record. It survives validation fixes, retries, and app restarts. The server uses it to guarantee the same logical document is never created twice.
- A mutation attempt identity, one per exact outbound payload. The server keeps a ledger of processed attempts; replay an unchanged attempt and it returns the original result instead of re-executing. Change the payload after a rejection and the client mints a new attempt identity while keeping the same document identity.
The response lost in transit is now harmless. Retry, get the recorded result, move on.
Dependency ordering for composite writes
Documents with headers and lines replay as one atomic mutation, modelled as a document graph rather than one row per table. Records created offline get temporary local identifiers; when the parent lands and the server returns its real identifier, the outbox remaps every child reference before the children are sent. If a parent write is rejected or hits a conflict, its dependents move to a blocked state and are not sent at all — they would only fail their reference checks. They unblock when the parent succeeds, or are discarded with it.
Retry is a taxonomy, not a loop
When a replay fails, the worker classifies the failure before deciding anything:
- Transient — network errors, timeouts, server hiccups. Retry with exponential backoff; resume automatically on reconnect.
- Auth expired — pause the queue, refresh the session silently, resume. Never drop work because a token aged out while the phone sat in a glovebox.
- Conflict — the record changed on the server since this device last read it. Route to conflict resolution, not retry.
- Rejected — a validation or business-rule failure. Stop retrying; it can never succeed as-is. Persist the server's field-level errors and flag the record.
Rejected writes get a first-class fix flow. A pending-changes screen lists everything queued and everything failed. Opening a failed write pre-fills the form with the rejected payload and shows the server's errors inline on the offending fields. Fixing and resubmitting keeps the same document identity — so the fix cannot spawn a duplicate — under a new attempt identity, because the payload changed. Discard is always an explicit user action, never something the queue does on its own.
Conflicts: field by field, not record by record
Every offline edit stores the base version of the record it was made against. On replay, the write carries that base version; if the server row has moved on, the server refuses the stale write and returns the current row rather than silently overwriting anyone.
The app then has three versions — the base, the local edit, and the current server row — which is enough for a three-way, field-by-field merge. If the rep changed the delivery note and the office changed the credit terms, both changes survive without anyone choosing sides. Only when both sides touched the same field does the user see a decision, scoped to that field.
Photos: copy first, queue second
Attachments get one extra rule: before an upload is queued, the file is copied out of the camera roll or share sheet into the app's own durable storage. The queued upload references the copy. The OS can purge caches, the user can clear the gallery, the upload can wait a week — the bytes are already owned by the queue that promised to deliver them.
What this buys
Trust, mostly. A rep can capture an order in airplane mode, force-quit the app, reopen it the next morning, and watch the pending badge drain to zero on the first bar of signal — exactly once, in the right order, with any conflicts surfaced instead of swallowed. The design is not exotic. It is a durable queue, two identity keys, a failure taxonomy, and the discipline to route every single write through them. The last mile is where field software earns its keep, and none of the shortcuts survive it.
