Most teams treat offline-first as a checkbox near the end of a project. Someone adds a service worker, the Lighthouse audit goes green, and the app technically loads without a network. Then an agent crouches by a cold store at dawn with one bar of signal, thumbs a spray record against a plot, watches the request hang, taps submit again to be sure, and now there are two spray records against that plot. The service worker did its job perfectly. The system still produced garbage.
We build a lot of software that runs where the network is bad and the device is whatever the user already owns: a contract-farming platform for an agribusiness group, a channel-sales system for its agri-inputs arm, a booking and ticketing platform for a state tourism corporation. Across all of them the same thing held. Offline-first is a set of decisions you make in the data model on day one. Make them at the end and you are not adding a feature, you are rewriting the app.
The data model decides this, not the cache
The question that actually settles whether your app survives a bad network is a plain one: what is the unit of truth, and can it be created twice? On the agribusiness platform the honest answer was that every field operation is a first-class record against a specific plot. Soil preparation, planting, fertiliser, irrigation, pesticide, rouging, dehaulming, a germination check, the harvest. Each is a row a human creates on a phone in a field. The moment you accept that a human on a flaky connection is going to retry, you have accepted that every one of those writes needs an identity the client decides, before the request ever leaves the device.
That is a schema decision. The client generates an idempotency key up front, and the server treats a second arrival of the same key as the same operation. It is trivial to add on day one. It is miserable to retrofit, because retrofitting means backfilling identity onto records that already exist without it and reconciling the duplicates you have already shipped to production. I know that because we shipped the first of these systems without client-generated ids, and I then spent the better part of a month reconciling spray records that had been written twice by tired people at dawn. Do it first.
The expensive part is idempotency and reconciliation
Caching is the cheap fifth of offline-first. The expensive four fifths is what happens when a write finally reaches the server, possibly twice, possibly out of order, possibly after the state it assumed has already moved. On the sales and distribution system this shows up as reconciliation: plan against booking against payment against what actually shipped. Payments especially do not resolve synchronously. A gateway confirms on its own schedule, so a reconciliation job runs on a background worker tier, pulls pending payments and settles them against the record. Nobody presses a button, and the field app never blocks waiting for it.
That worker tier is the other half of the offline story. Anything slow or bursty (document generation, scheduled reporting, announcement fan-out, sensor ingestion from an agronomy sensor provider) runs on a managed queue on its own beat, whether or not the person who triggered it still has the app open. Offline-first on the client and asynchronous processing on the server are the same design pressure seen from two ends. The moment of intent and the moment of durable effect are not the same moment, so you may as well stop pretending they are.
Caching is the easy part, and even that has a sharp edge
For the read path the honest default is network-first with a short timeout and a cache fallback, so a good connection always shows fresh data and a bad one shows the last-known answer instead of a spinner. The configuration is a few lines:
runtimeCaching: [
{
urlPattern: ({ request, url }) =>
request.method === "GET" &&
isApiHost(url) &&
!url.pathname.includes("/auth/"),
handler: "NetworkFirst",
options: {
cacheName: "api-get",
networkTimeoutSeconds: 5,
expiration: { maxEntries: 100, maxAgeSeconds: 60 * 5 },
cacheableResponse: { statuses: [200] },
},
},
]The sharp edge is the exclusions. Auth endpoints must never be cached, or you will cheerfully serve someone a stale session. The navigation fallback that lets the app boot offline has to deny-list the API and auth paths, or the service worker starts answering requests it has no business answering. These are one-line rules, and each missing one is a silent, awful bug that surfaces days later.
The device will throw your data away
For the tourism platform we cache issued tickets on the device so a visitor can board at a jetty with no signal. The mechanism is dull: on every successful fetch, snapshot the payload to local storage keyed by the booking; when the live request fails, render the snapshot.
useEffect(() => {
if (!liveData) return;
const payload = { data: liveData, cachedAt: Date.now() };
localStorage.setItem(storageKey(id), JSON.stringify(payload));
}, [id, liveData]);
// On a failed request, the component reads the snapshot instead.What is not dull is that iOS aggressively evicts WebView local storage under memory pressure. A ticket cached this morning can be gone by lunch, and for a boarding pass that is the one failure you cannot ship. So on native builds the durable keys (the token, the locale, the half-finished booking draft) are mirrored into platform-native storage, backed by the OS preference store, and hydrated back at startup. You do not get to assume the browser storage you wrote to is the browser storage you will read from. On a real device, in a real pocket, it is not.
One-time codes over SMS, on a shared phone
Every one of these products authenticates with a one-time code sent over SMS. That is a decision about the actual user. A farmer or a field agent shares a phone, has no email account they check, and will not remember a password they set six months ago. A password is a thing to forget, and then a thing to reset over a channel they do not have. A phone number is the one stable identifier they always carry. The identity model just follows: the mobile number is the account, verification is a boolean on it, and there is no password column to leak or reset. When your users share devices and forget emails, the password is the worse experience and the worse security posture at the same time.
Four languages change the schema, not just the strings
The agribusiness app ships in four languages. The naive reading is that this is four files of interface strings behind a translation library, and for buttons and labels it is exactly that. The part that reaches the schema is content. A crop-operation label, an announcement pushed to farmers, a workflow stage name, a training-video reference: this is authored data, not interface chrome, and each piece has to be legible to a user in their language and to a head-office reviewer in English at the same time. Language becomes a property of the row. Announcements are fanned out per audience and per language on the worker tier. On a multi-tenant consultancy platform we built, even workflow stage names are translatable content, which makes them a table rather than a constant. You feel this in the schema long before you feel it in the interface.
A web app in a native shell is a real architecture, until it is not
For the sales platform we ship one progressive web app and wrap it in a thin native shell for the app stores: a web view pointed at the app, plus a small message bridge for the two things a web page genuinely cannot do well, native push tokens and camera-based document capture for assisted onboarding. The shell gates navigation so same-origin stays inside the web view and everything else is handed to the operating system. This is a legitimate architecture. You get store distribution, push and camera without maintaining a second codebase, and the product team ships once.
It stops being the right call when the app's value is the native surface itself: heavy gestures, background location, tight camera or sensor loops, animation where a web view's jank is the product. The tourism apps sit comfortably in the shell because the value is the booking, not the frame rate. If your differentiator is the frame rate, the shell is a tax dressed as a shortcut. Know which one you are before you commit.
When not to build offline-first
Offline-first is not free. Client-generated identity, a sync and reconciliation path, conflict handling, durable native storage, a worker tier: all real surface area, all of it buildable wrong. So do not pay for it by reflex.
- If the user is always on good office wifi, network-first caching is plenty and full offline write support is dead weight.
- If a write must be strongly consistent at the instant it happens, such as a payment authorisation or a stock ledger that cannot go negative, do not queue it offline and pretend it succeeded. Fail honestly and make the user retry when they have signal.
- If two users editing the same record offline is even possible, you have signed up for conflict resolution, which is expensive and often has no automatic right answer. Prefer a model where each record has one owner and writes are append-only, so retries are idempotent and there is nothing to merge.
The service worker will pass its audit either way. That was never the thing keeping me up. What keeps me up is the row that got written twice at dawn in a field, months before anyone on the team thought about the cache, because we had decided its identity on the wrong side of the network. You get to make that decision exactly once.