Designing Graceful Offline Mode for Field Operations Software
By Aldridge Dagos, operations software engineer
An acquisition agent is standing on 40 acres outside a county seat with one bar of signal. They log the call outcome, set a follow-up date, and tap save. The spinner turns. Somewhere between the truck and the tree line the request dies, and the app has three options: lose the entry, show a red error the agent cannot act on, or take the entry and promise to deliver it later. Only the third one is a graceful offline mode, and the promise is harder to keep than it sounds, because the same tap can easily arrive twice or arrive in the wrong order once signal returns.
I designed the field side of a land acquisition system where the people using it are outdoors more than they are at a desk. Coverage is a design input there, not an edge case.
The short version: A graceful offline mode is an outbox, not a cache. Every action the user takes becomes a queued record with a client-generated id and a monotonic sequence number, written to IndexedDB rather than localStorage. On reconnect a single replayer walks the queue in sequence order and stops at the first failure instead of skipping ahead, so a status history cannot reorder itself. The server treats a repeated id as a no-op, which is the only thing that makes retrying safe. Two platform facts shape all of it: Safari’s tracking prevention clears every kind of script-writable storage after seven days without a visit, and the Background Sync API has never shipped in Safari or Firefox.
What does a graceful offline mode actually have to guarantee?
Four things, and dropping any one of them produces a distinct kind of wrong.
- Nothing the user typed is lost. The write lands locally before the network is ever consulted, and the UI confirms on the local write.
- Nothing arrives twice. A retried request creates zero new records, no matter how many times it fires.
- Nothing arrives out of order. If the agent logged “no answer” then “appointment set”, the server must never end up with “no answer” as the latest state.
- The user can see the queue. Pending work is visible and countable. A silent queue is indistinguishable from a broken one.
Most field apps get the first one and stop, because the first one is the only one you can see in a demo. The other three only show up as data corruption a week later, when a record’s history reads backwards and nobody can explain why.
Why localStorage is the wrong place for the queue
It is the obvious choice and it fails on three counts.
It is synchronous, so every read and write blocks the main thread, and a queue that grows to a few hundred entries turns into visible jank on the exact low-end phone a field team is carrying. It stores strings only, so every entry is a JSON.stringify round trip. And it caps out around 5 MB in practice, which sounds generous until someone attaches photos.
IndexedDB is asynchronous, stores structured values including binary, and holds far more. It is more awkward to use directly, which is what wrappers exist for, and the awkwardness is worth it.
Now the fact that catches almost everyone. Safari’s Intelligent Tracking Prevention applies a seven-day cap to all script-writable storage, and that list includes IndexedDB, localStorage, sessionStorage, and service worker registrations along with their caches. Seven days without the user opening your site and the queue is gone, along with the offline data the app was showing. The counter resets each time they visit, so an app used daily never notices. An app used on a two-week land trip absolutely does.
The mitigation is to ask for persistent storage explicitly with navigator.storage.persist(), which returns a promise resolving to whether the request was granted, and to treat a refusal as real. Installing the app to the home screen also changes how the platform treats it. Neither is a guarantee you can assume, so the queue should also be small, drained often, and never the only copy of anything that matters.
| Option | Durability | Ordering | Browser support | What it costs you |
|---|---|---|---|---|
| localStorage | Weak, synchronous, about 5 MB | You implement it | Everywhere | Main-thread jank as the queue grows |
| IndexedDB | Good, large, structured | You implement it | Everywhere | A wrapper, and an eviction story |
| Background Sync API | Delegates the retry to the browser | You implement it | Chrome, Edge, Opera, Samsung Internet only | Nothing on iOS, because it does not exist there |
| Hosted sync engine | Strong, handles conflicts | Built in | Everywhere | A dependency, a bill, and a data model you no longer own |
The outbox: one record shape that makes replay safe
The queue is not a list of failed requests. It is a log of intents, and each intent carries everything the replayer needs to send it correctly a week later.
// One queued intent. The id and the seq are created on the device,
// before the network is ever consulted.
const entry = {
op_id: crypto.randomUUID(), // the server's dedup key
seq: nextSeq(), // monotonic, per device
endpoint: '/api/outreach',
method: 'POST',
body: { parcelId, disposition: 'appointment_set', at: isoNow },
queued_at: isoNow,
attempts: 0,
last_error: null,
};
await db.outbox.add(entry); // local write first
render({ status: 'queued' }); // the UI confirms on this, not on the network
Two fields carry the whole design.
op_id is generated on the device and never regenerated on retry. That is the entire idempotency story, and getting it wrong is the classic bug: if the id is created at send time instead of at queue time, every retry is a fresh operation and the agent’s one tap becomes four call logs. RFC 9562, published in May 2024 and obsoleting RFC 4122, adds UUIDv7, which puts a 48-bit Unix millisecond timestamp in the most significant bits and fills the rest with random bits. That makes the ids time-ordered, which is friendlier to a database index than a fully random v4 and gives you a rough ordering for free.
seq is the per-device counter, and it exists because timestamps are not trustworthy on a device the user can reset. Two entries created in the same millisecond need a tiebreaker, and a phone whose clock jumps needs an ordering that does not.
Storing the endpoint and body rather than a serialized request object matters too. A queue entry might sit for six days, and when it finally sends, the auth token it was created with is long expired. The replayer attaches a fresh token at send time, which it can only do if the entry holds data instead of a pre-built request.
Replay in order, and stop at the first failure
The replayer is short, and every line in it is load-bearing.
async function drain() {
const pending = await db.outbox.orderBy('seq').toArray();
for (const e of pending) {
try {
const res = await fetch(e.endpoint, {
method: e.method,
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': e.op_id, // convention, not a standard
Authorization: `Bearer ${await freshToken()}`,
},
body: JSON.stringify(e.body),
});
if (res.status >= 500 || res.status === 429) throw new Error(`retry ${res.status}`);
if (!res.ok) { // 4xx: it will never succeed
await db.outbox.update(e.id, { status: 'rejected', last_error: await res.text() });
continue; // park it for a human, keep draining
}
await db.outbox.delete(e.id);
} catch (err) {
await db.outbox.update(e.id, { attempts: e.attempts + 1, last_error: String(err) });
return; // STOP. Order is the guarantee.
}
}
}
That bare return is the part people delete because it looks like a bug. It is not. Skipping a stuck entry and sending the next one is how “no answer” lands after “appointment set” and the record’s history reads backwards. Halt the drain, back off, and try the whole queue again from the top.
Distinguish the two failure classes carefully. A 500 or a 429 is transient and the entry stays queued. A 422 or a 409 is a decision, and retrying it forever just burns battery, so it gets parked with its error visible and a person resolves it. Back off with jitter between attempts rather than a fixed interval, because a whole crew regaining signal in the same parking lot will otherwise hit your API in the same second.
The server has to be idempotent too
A client-side id does nothing if the server ignores it. The receiving endpoint needs a uniqueness rule on the operation id, and that rule belongs in the database.
create table outreach_events (
id bigserial primary key,
op_id uuid not null,
parcel_id bigint not null references parcels(id),
disposition text not null,
occurred_at timestamptz not null,
created_at timestamptz not null default now()
);
create unique index outreach_events_op_id on outreach_events (op_id);
Insert with on conflict (op_id) do nothing and return the existing record. A duplicate then costs one rejected insert and produces a 200, which is exactly what the replayer needs to hear so it can delete the entry and move on. This is the same spine that keeps a burst of phone webhooks from turning into lost or doubled call records, and the reason to put it in the database rather than in application code is the same as everywhere else: the constraint cannot be forgotten by the next code path someone adds.
Worth knowing where the Idempotency-Key header stands. It is a widely followed convention, popularised by payment APIs and documented in Stripe’s idempotent requests guide, and the IETF has been working on it in the HTTPAPI group. That work reached revision 07 on 15 October 2025 and has since expired without becoming an RFC. Use the header, because everyone recognises it. Do not describe it to a client as a standard.
Background Sync will not save you on iOS
The Background Synchronization API lets a service worker defer a task and have the browser fire it once connectivity returns, which is exactly the machinery this problem wants. It ships in Chrome from version 49, Edge from 79, Opera from 42, and Samsung Internet from 5.0. It has never shipped in Firefox, and it has never shipped in Safari on macOS, iPadOS, or iOS, in any version.
For a field team on iPhones, that means the browser will not wake your app up. The drain runs when the app is open, and you trigger it on three signals: app launch, the online event, and a visibility change back to the foreground. Treat Background Sync as a bonus on Android, never as the design.
- Show the pending count in the chrome of the app. “3 waiting to sync” tells the agent the truth. A silent queue looks identical to a lost entry.
- Confirm on the local write, not on the response. The user’s job is done when the intent is durable.
- Never let the queue be the only copy. Drain on every foreground and every reconnect.
- Cap the queue and warn before the cap. A phone that has been offline for a week should say so, loudly, before storage pressure decides for you.
- Log the device clock alongside the server clock. When the two disagree by hours, you want to find that in the data instead of in a support call.
- Test with the radio off, not with throttling. Throttled slow and genuinely absent are different failure modes, and only one of them exercises the queue.
Coverage is a requirement, not an excuse
Most field software is written by people with five bars, tested by people with five bars, and demoed in a room with good wifi. Then it ships to somebody standing in a field, which is where the actual work happens and where the network was never going to cooperate.
Build the outbox first and the online path becomes a special case of it, the case where the drain happens to run immediately. Build it second and you spend a quarter reconciling duplicate logs nobody can untangle.
The tally counter in your hand does not need a signal. Neither should the form.
Frequently asked questions
How do I build an offline mode that does not create duplicates?
Generate the operation id on the device at the moment the user acts, never at send time, and store it with the queued entry. Send it on every retry, and put a unique index on that id in the database so a repeat insert is a no-op that still returns success. If the id is created when the request goes out, each retry looks like a brand new action and one tap becomes several records.
Should I use localStorage or IndexedDB for an offline queue?
IndexedDB. localStorage is synchronous, so it blocks the main thread on every read and write, stores strings only, and caps around 5 MB in practice. IndexedDB is asynchronous, holds structured and binary values, and scales to real queue sizes. Both are subject to browser eviction, so ask for persistent storage with navigator.storage.persist() and design as though the request may be refused.
Does Safari support the Background Sync API?
No. The Background Synchronization API has never shipped in Safari on macOS, iPadOS, or iOS, in any version, and Firefox does not support it either. It is available in Chrome 49 and later, Edge 79 and later, Opera 42 and later, and Samsung Internet 5.0 and later. On iPhones the queue drains when your app is open, so trigger it on launch, on the online event, and when the page becomes visible again.
How long does browser storage survive without the user opening the app?
On Safari, seven days. Intelligent Tracking Prevention applies a seven-day cap to all script-writable storage, covering IndexedDB, localStorage, sessionStorage, and service worker registrations and caches, and the counter resets on each visit. A field app used daily never sees this. One used on a two-week trip can lose its entire queue, which is why draining often and requesting persistent storage both matter.
What order should offline actions replay in when connectivity returns?
Strictly the order they were created, tracked with a monotonic per-device sequence number rather than a timestamp, because device clocks drift and can be reset by the user. The replayer walks the queue in sequence and stops at the first transient failure instead of skipping ahead. Skipping is what lets a later status land before an earlier one and leaves a record whose history reads backwards.