Skip to content
(01) // PRODUCT

Offline-first is a product decision, not a technical one

5 MIN READPRODUCTUPDATED 15 SEPT 2026

Before building a sync layer, decide what is readable offline, what is writable offline, and who wins a conflict. Those are product calls, not engineering ones.

“It should work offline” is not a specification. Three questions turn it into one, and none of them are technical.

Engineers are usually happy to build a sync layer. The problem is that a sync layer encodes answers to questions nobody asked out loud, and those answers turn out to be business rules — who loses a race for the last unit of stock, whether a signature captured with no signal is legally captured, what a driver is allowed to promise a customer while disconnected. If the engineering team invents those answers, the business will discover them in an incident.

What is readable offline?

Usually today’s work: the jobs assigned, the stock in this location, the records for this customer. Deciding the scope decides how much you cache and how stale it is allowed to be.

Two follow-ups make it concrete. How stale is acceptable, per data type? A price list from this morning is probably fine. A job assignment from this morning may not be, if dispatch has since reassigned it. Staleness tolerance varies by field, and stating it per type is what tells you what to sync eagerly and what to fetch on demand.

And what happens at the edge of the cached set? A user who scrolls past the end of what you cached needs to see something honest — “not available offline” — rather than an empty list that reads as “there is nothing here”. Silent absence is the most common offline bug, and it is a design bug rather than a sync bug.

What is writable offline?

Every offline write is a promise you may not be able to keep. Capturing a signature offline is fine. Reserving the last unit of stock offline is a business decision about who loses.

The useful way to sort this is by whether the write is a record of something that happened or a claim on something scarce.

Records — a signature, a photo, a completion timestamp, a note, a meter reading — are safe offline. They happened; the device is simply the first witness. Sync is a transport problem.

Claims — reserving stock, booking the last slot, assigning a driver, spending a budget — are not safe, because two devices can make the same claim while neither can see the other. You have three honest options: forbid the write offline; allow it and accept that some will be rejected on sync, with a clear path for the user when that happens; or hold a per-device allocation, so each device offline can only claim from a pool reserved for it. All three are defensible. Pretending the problem does not exist is not.

Who wins a conflict?

Last write, server authority, or a human review queue. Pick per field, not per application. A note field can merge; a status field cannot.

Per-field is the whole insight. “Last write wins” applied to an entire record means a driver’s status update silently discards a planner’s edit to a completely unrelated field on the same row, because both wrote the whole object. Diffing at field level removes most conflicts entirely — the two writes were never actually in conflict.

For the fields that genuinely collide, pick a rule with a reason behind it. The person physically present usually beats the person at a desk: a driver marking a delivery complete has information the office does not. Money and stock usually go to server authority, because the cost of a wrong answer is asymmetric. And a small number of cases deserve a human queue — but only if someone is actually staffed to work it, otherwise you have built a place for problems to accumulate unseen.

Then build the sync layer

Once those three answers exist, the engineering is ordinary: a local store, an outbox of queued mutations, idempotent endpoints, and a visible sync state so the user knows what has actually been saved.

Ordinary, with a few details that reliably bite:

The outbox must survive the app being killed. An in-memory queue loses a day’s work when iOS reclaims memory in a van at lunchtime.

Order matters within a chain. A create followed by an update to the same record has to sync in that order, with the server-assigned ID mapped back. This is where most homegrown sync layers first break.

Endpoints must be idempotent. Retries are the normal case, not the exception. Without idempotency keys, one flaky connection produces two of everything.

Sync state has to be visible and honest. Saved-on-device and saved-on-server are different states and users need to see which one they are in. A tick that means “we will try later” is a lie users only discover on the day it matters.

Clocks on devices are wrong. Sometimes deliberately. Never resolve a conflict by trusting a device timestamp; use server receipt order or a logical clock.

The trade-offs

Offline-first roughly doubles the state in your application — there is now a local truth and a remote truth, and every screen can be in either. Testing gets meaningfully harder: the interesting bugs live in the transitions, and you need a way to simulate flaky connections rather than only offline and online. And a local store on a lost device is data in the world, so encryption at rest and remote wipe move from nice-to-have to requirement.

For a field team with unreliable signal, that cost is obviously worth paying — it is what makes a dispatch system usable at all. For an office application on stable wi-fi, it is complexity bought for a scenario that does not occur, and a good loading state is the better answer.

It is a scoping conversation we have early on mobile work, and it is why inventory systems are designed around the handheld rather than around the server.

Sources and further reading

mobileofflinesync
START A
PROJECT
EMAIL CHANNELHELLO@BEECODEX.COM
SYSTEMS ONLINE / TAKING NEW PROJECTS