All posts9 June 2026

Offline-first field apps: an architecture that survives a basement

The architecture we ship for field work: local store as source of truth, an event outbox with dependency edges, foreground sync, one database per engineer.

W. Akram13 min readengineering

Offline-first means the device stops waiting for the server to do its job. For field work we ship four things: a local database as the source of truth, an append-only outbox with explicit dependencies between queued writes, sync that only runs in the foreground, and one database per engineer that logging out never deletes. Everything else follows from those.

Offline-first means the local database is the application's source of truth and the server is something the device reconciles with later. The architecture below comes from an engineer client we built for a field operations platform in a regulated compliance industry, where the people using it work in basements and plant rooms, and the output is a compliance record somebody reads back under scrutiny.

The shape of it

+-------------------------------------------------------+
|  Engineer's device                                     |
|                                                        |
|   Screens  --read-->  Local store (IndexedDB)          |
|      |                       ^                         |
|      |                       | replay                  |
|      +--write-->  Event outbox (append only)           |
|                              |                         |
|                              | drain order:            |
|                              |   per-job FIFO          |
|                              |   plus dependsOn edges  |
|                              v                         |
|                      Sync engine (foreground only)     |
+------------------------------|------------------------+
                               |
                               v
                        +---------------+
                        |   Server API  |
                        +---------------+

Nothing on the device side of that boundary blocks on anything below it. A screen reads the local store and paints. A button appends an event to the outbox and updates the projection the screen is already showing. The sync engine drains the outbox when it has both a network and a foreground, then folds the server's response back into the local store. Two stores on purpose: one holds what the engineer sees, the other holds what the engineer owes the server.

The server is a peer this device catches up with, not a dependency it waits on. That one relationship is the whole idea. It is also the first thing to get quietly inverted, usually in a screen nobody thought of as offline work: a settings page, a site picker, a lookup that fetches a list. An engineer opens it in a plant room and the app stops dead.

The local store is the source of truth

Every screen reads IndexedDB, synchronously, on mount, before any request leaves the device, then revalidates behind whatever is painted. Our rule is that no screen has a loading state depending on the network. That sounds obvious and is broken by nearly every app claiming offline support, usually by a fetch-on-mount treating the cache as a fallback instead of the primary read.

That inversion removes most of the four ways offline apps fail in the field. It also hands you a cost: you now own a database schema on a device you cannot reach.

Concretely. Our engineer client went through five schema versions across seven object stores in about twelve weeks, and each migration runs unattended on a tablet in somebody's van, possibly two releases behind. So migrations are additive and idempotent, and anything that cannot be made additive becomes a new store. Blobs stay blobs and never become base64, which costs a third of the bytes you do not have.

You also own eviction, because a browser can throw your data away. Ask for persistent storage, record whether the request was granted, and put that answer in your diagnostics: "the work vanished" and "the browser evicted an unpersisted origin" are the same incident report until you can tell them apart.

Writes are events, not updates

Nothing an engineer does is stored as an update to a row. It goes into the outbox as an event: appended, stamped with the device's clock, never edited afterwards. Seven event types cover the whole engineer workflow. The record on screen is a projection: the last state the server sent, with the device's unsent events replayed over the top.

The alternative most teams reach for is queueing HTTP requests. Take the PATCH you would have sent, put it in a list, send it later. That works until the state underneath moves, at which point you hold a diff whose meaning has changed and no way to notice. An event keeps the intent (the engineer answered question four with this value at 09:41) rather than the transport, and intent survives reordering, retry and a renamed field.

We chose an event outbox over CRDTs on purpose, and the reasoning sits in the piece on how offline apps handle sync conflicts. The short version: CRDTs buy correct concurrent editing, and field work has almost none, because one engineer is at the asset. What it has instead is a duty to explain what happened, and an ordered log of intent-shaped events is something you can hand an auditor. A CRDT's merge state is unreadable.

One warning about projections. Two views of a record can disagree, and the bug reports arrive as "the list says one thing and the detail page says another". Every read goes through a single projection function, which is the most tested code in the app.

Queued writes need dependency edges, not just an order

Draining the outbox in order is not enough. A form submission addresses its visit by a server id, and that id does not exist until the visit's start event comes back with a 201. The same submission references photos, and pointing at one that has not finished uploading produces a record referencing nothing.

So each queued event carries explicit dependsOn ids alongside its position in the queue. Drain order is per-job FIFO plus those edges. The drainer will not send an event whose dependencies are unsent, and it will not hold unrelated work behind them.

That second half earns the complexity. Without edges you get one of two behaviours and both are bad. Either the queue is strictly global, so one photo failing on one bar of signal blocks every other job's paperwork for the shift. Or it sends everything eagerly, and you spend your time reverse-engineering 400s to sort "not yet" from "never".

Not every app needs this. If your writes are independent, a FIFO queue with retry and a dead-letter path is the right amount of engineering and stopping there is correct. We built the edges because a job's evidence is a bundle whose parts refer to each other, and a half-sent bundle is worse than an unsent one.

Sync runs in the foreground, because half the fleet has no other option

The textbook answer to "send it when the signal returns" is the Background Sync API. When we checked, Safari shipped none of it: no Background Sync, no Periodic Background Sync, no Background Fetch, on any iOS version we could test. Since much of any field fleet is on iPads, that API would leave half of them silently never syncing, and nobody finds out until an audit.

So there is no service worker queue at all. Sync is a foreground singleton started once auth resolves, with six triggers: app launch, the browser's online event, the tab becoming visible, a thirty-second interval, a 600ms-debounced kick after each local mutation, and a manual control.

Two details only appear once real devices are involved. A Web Lock stops two tabs draining one queue at once. And a reset-stale-syncing pass runs on entry, because iOS reaps backgrounded web apps mid-request, leaving events marked in flight that are not.

The rejected alternative is written down: Background Sync with a foreground fallback. The fallback carries the whole load for half the users anyway, so the extra path buys a route that never runs where it matters. One path that always runs beats two where the better one is dead.

So nothing syncs while the app is closed. That went into the spec as a product constraint, not a bug we hoped nobody would file. We sync on open and keep a banner counting unsent changes, so nobody drives home believing the work has left the device.

One database per engineer, and logging out never deletes it

Each engineer gets their own IndexedDB database on the device, named from the identity token's subject. Two people on one tablet get two stores that never see each other. Logging out clears the token and touches nothing else.

That last part gets argued about, so here is the reasoning. Office thinking assumes devices are shared and logout is when you clean up after a user. Field engineers share devices far less than that: a van has a tablet and the tablet has a person. And the cost of guessing wrong is asymmetric. Keep data you should have cleared and the worst case is a stale cache on a company device. Clear data you should have kept and a day of evidence is gone.

The scenario that settles it: a token lapses while the engineer is in a basement with no signal. If expiry triggered a wipe, the app would delete the morning's readings at exactly the moment it cannot upload them. So expiry ends the session, leaves the store alone, and the events drain after the next login.

Nothing reaps old databases yet, so they accumulate. The diagnostics report counts how many other engineers' stores sit on a tablet, and the number has never been big enough to act on. When it is, the reaper gets an age threshold and a rule against touching any store holding unsent events. Making evidence survive the device happens on the server: see proving an offline capture later.

A server-side change never deletes local work

The office can cancel or reassign a job while an engineer stands in front of the asset with a half-finished form. Our first implementation mapped cancelled to "drop the local bundle", which is the obvious reading of a sync response and also how a morning's readings vanish while sitting unsynced in IndexedDB.

The rule now sits above every other case. Local work is deleted by a successful sync or by the engineer wiping the app, and by nothing else. A record holding local work gets flagged, stays visible, and goes read-only with a notice to check with the office. A record with no local work is stale cache and disappears quietly. A diverged job that comes back live clears its flag.

Two alternatives were rejected, both recorded. Auto-discard, because the office has no way of seeing what is sitting unsent on a device when it presses cancel. And a modal forcing an immediate keep-or-discard choice, a destructive decision on a wet tablet screen with the next job waiting.

The offline feature we shipped and then deleted

Engineers hit sync failures the office cannot see. Our first answer was a button in the settings screen: send diagnostics to support. It filed a truncated payload as a card on a project board. It shipped, it did what it said, and it was the wrong shape.

The problem was what it required of the engineer: notice something is wrong, care enough to act, find the button and press it, in a plant room, with a job half done. The failures that mattered most were the ones nobody noticed.

So we removed it. Failures now file themselves the moment an event dead-letters, one report per failure, with the event type in the title so a recurring problem reads as a recurring problem. The device state rides along: queue counts, recent events with payloads, per-record sync states, whether storage persistence was granted. A retry that fails differently clears its reported flag, because a fresh rejection deserves a fresh report.

One screen survived: the last few failures, on the device, for support calls without a cable.

The privacy trade-off went the other way, and we would rather write it down than rediscover it in a year. Form answers now leave the device inside those reports, which the previous channel's rules did not allow. That is in the decision record with the reasoning and who signed it off. Scrubbing runs over the serialised JSON, not the object graph, so image data buried in answer strings gets stripped.

We would make the same call on a system with no offline component in it at all. If the reporting path waits for somebody to notice, it will miss precisely the failures worth knowing about.

The decisions in one table

DecisionWhat we shipWhat we rejectedWhy
Source of truthLocal store, read synchronously on mountNetwork first with a cache fallbackA stalled request never rejects, so the fallback never fires
Write modelAppend-only event outboxQueued HTTP requests, or CRDTsIntent survives reordering, and an ordered log is legible to an auditor
Drain orderPer-job FIFO plus explicit dependsOn edgesOne global queueA stuck photo should block one form, not the whole shift
Sync triggerForeground only, six triggersBackground Sync with a foreground fallbackSafari ships no background sync APIs, so the fallback carries half the fleet
Local lifetimeOne store per engineer, survives logoutWipe on logoutA stale cache is an annoyance, lost evidence is not recoverable

Every row trades a visible cost for an invisible one. That is a defensible order of preference in field work and a slightly odd one at a desk, where nobody loses a day's evidence to a dead radio.

When not to build this

Two questions decide it. Can the person using the app be somewhere with no usable signal, and does losing their work matter beyond an inconvenience? Two yeses and you want the architecture above. One yes and a read-through cache with a banner marking stale data will do.

Because the effort is not small. Our engineer client runs to roughly fifteen thousand lines, twenty-four of its files are tests, and most of those cover the outbox and the projection, not the interface. That is the price of a local write path you can defend in an audit. For an app whose users sit at desks on office wifi it would be absurd, and we would not build it.

This is the shape we ship when we build field operations software for work that happens away from a desk. The difference is whether the absence of a network is an edge case or a normal Tuesday morning.

Common questions

What does offline-first mean in practice?

It means the local database is where the app reads and writes, with the server reconciled afterwards. The test is whether any screen shows a loading state when the device already holds the data. If it does, the app is online-first with a cache bolted on.

Do I need CRDTs to build an offline-first app?

Usually not. CRDTs solve concurrent editing of one record by several people at once. Most field work has one writer at the asset and one in the office, which an event log with explicit ordering handles at a fraction of the complexity, and with a history a human can read.

Can a progressive web app sync in the background on an iPhone or iPad?

No. Safari has not shipped Background Sync, Periodic Background Sync or Background Fetch on any iOS version we have tested, and nothing has been announced. If your fleet includes iPads, plan for foreground sync with a visible unsent-changes indicator and treat background sync as unavailable.

Should logging out clear the local database?

Not in field software. An expired token in a dead zone would destroy unsent work at the moment it cannot be uploaded. Keep one store per user, named from the identity token's subject, and clear it on a successful sync or an explicit wipe by the engineer.

Let's talk

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.

Free 30-min call. No pitch.