Let's talk
engineering

An action belongs in an offline queue only if a replay can be proved either way

A driver arrives at a customer’s gate with no signal, opens the app to record the delivery, and gets a loading spinner that never resolves. The write queue behind that screen worked perfectly. It was unreachable, because the detail screen fetched the dispatch from the network and there was no cached single-record read.

“Offline delivery capture” had been built and could not be performed offline. The queue is the part everyone thinks about, and it is the second half of the problem. The first half is whether the user can get to the form at all.

Adding a cached single-record read fixed it, and the general shape is worth stating: an offline write is only useful if every screen on the path to it also works offline. The list, the detail, the picker that populates a field. Any one of them reaching for the network turns the whole feature into a demonstration.

The test for whether an action can be queued

Once the path works, the harder question is which actions may be queued at all. The answer in this project came down to one test.

A queued write is replayed later. It may be replayed against a server that already applied it — the original request landed and the response was lost — or against one that never saw it. The queue must end up with the right answer either way, and it can only do that if a replay is distinguishable.

If a replay of this action cannot be proved either applied or not applied, it must not be queued. That is the rule, and it sorts the actions cleanly.

A create carrying a client-generated identifier is provable. Replay it and either the server accepts it or it rejects it as a duplicate of that exact identifier, and a duplicate of your own identifier means it landed. If the server says something else, the record can be looked up by that identifier and the question settled by asking.

A state transition is a different matter. Delivering a dispatch, cancelling an order, moving an opportunity to won — these carry no client identifier, because their request schemas reject one. Replaying them produces a conflict, and a conflict says only “the record is not in the state this transition expects”. It does not say why. It might be because you already applied it. It might be because somebody at the desk moved it somewhere else entirely.

Where the line landed

So the transitions were sorted individually, on evidence rather than convenience.

Queued: delivery and failure capture. Both are terminal, and both are only reachable from one prior state. A replayed delivery on an already-delivered dispatch produces exactly one conflict for exactly one reason, and treating that as “already applied” is honest. These are also the actions performed at a gate with no signal, which is why they mattered.

Online only: pack, dispatch, cancel. These are desk actions performed by someone with a connection, so the cost of requiring one is nearly zero. And a queued cancel replayed after somebody else moved the record would produce the same conflict as a successful replay, and be reported as applied when it was not. The benefit was small and the failure mode was a lie.

Online only: moving an opportunity through the pipeline, and marking it won or lost. Same reasoning, plus one more: marking an opportunity won can spawn a draft order. An action with a side effect that creates a record elsewhere is a poor candidate for optimistic replay.

Queued: logging an activity. It carries a client identifier, so it is provable.

That is not a policy anyone could derive from a diagram. Each one required knowing what the API returns on a replay, and each one was checked against the running system rather than inferred from the schema.

The proof photo, and telling the truth about it

Delivery capture optionally includes a photograph. The photograph cannot be queued, and the reason is structural.

The upload is a direct transfer to object storage using a short-lived signed grant obtained from the API. A queue that stores request bodies cannot store that: the grant expires, and the image bytes live in a local file that a serialised body does not carry. Pretending otherwise would produce a queued upload that fails silently hours later, long after the driver has left.

Two options: build a separate file queue that holds the bytes and re-requests the grant, or do not offer it offline. The second was chosen for now, and the important part is what the interface does about it. Before the driver confirms, the sheet says the photograph will not be sent because there is no connection. Not afterwards, not in a log, not as a badge somewhere else in the app. Before, on the screen where the decision is being made.

A capability you have chosen not to build offline must be declared at the moment of use. The alternative is a user who believes the photo is pending, and a dispute weeks later where the evidence does not exist.

Cached reads need a banner

Fixing the unreachable screen meant caching reads, and caching reads introduced a new way to be wrong: showing yesterday’s data as though it were today’s.

Every list and detail that gained a cache also gained an indication that what is on screen is the last successfully loaded copy. Without it, adding caching would have replaced a visible failure — a screen that will not load — with an invisible one, which is a worse trade even though it looks better in a demonstration.

The same principle governs the device capabilities the flow depends on. Location and camera permission can be denied, hardware can be unavailable, a user can cancel a picker. None of those block the delivery from being recorded. They are outcomes, captured as outcomes, and the delivery proceeds without them. A workflow that refuses to complete because a location fix was not obtained has decided that a coordinate matters more than the delivery, and no operations manager has ever agreed with that.

Rules

Verify the whole path offline, not just the write. List, detail, pickers. Any network read on the path makes the queued write unreachable.

Queue an action only if a replay is provable either way. Creates with a client identifier qualify. Most state transitions do not, and the conflict they return cannot distinguish “already done” from “somebody moved it”.

Decide each transition on the API’s actual replay behaviour. Check it against the running system. The schema will not tell you.

Declare offline limitations before the user confirms, on the screen where they are deciding.

Flag cached data as cached. Silent staleness is a worse failure than a screen that will not load, and it looks like success.

Working on something like this?

We build this kind of software, and we staff the teams that do.

Get in touch