On a route, at a gate, in a yard, the network is something that sometimes happens. An app that only works on a strong connection does not work there. The people using it will keep a paper backup, and then you have two systems, one of which you do not operate.
Offline-first is usually described as a stack: a local database, a queue, a sync engine. You will need those, but the real decision is about truth. What may the device believe while it is alone? Which actions may complete before the server has seen them? When the two disagree, what does the person standing next to the truck get to see?
Name the local truth
Write down the facts a device may treat as real on its own. A driver who has marked a trip started should still see that trip as started when the radio drops. A scan the device accepted should remain accepted on that device. If you cannot say this in a sentence a supervisor understands, the sync engine will invent the sentence during an incident, which is a bad time to invent product policy.
The server stays the system of record. Local truth is a lease. The interface has to stay usable for the length of that lease, and it has to be plain about which facts are still waiting on confirmation.
Optimism has a blast radius
Some actions are kind to apply immediately. They are reversible, and the damage of being wrong stays on one device. Marking a task done. Saving a note. Queueing a status the operator can still see as queued.
Some actions are not kind. Issuing a credential. Releasing a container. Cancelling something already paid for. Those want the server's answer, or they want a much narrower window and a pending state nobody can miss. Reversibility and blast radius are the test. If undoing the action is cheap, optimism is a courtesy. If undoing it touches money, access, or someone else's equipment, wait, and say that you are waiting.
A conflict is part of the interface
Two devices will disagree. Last-write-wins, applied in silence, is how an operator loses a scan they watched succeed and decides the app is ornamental. Trust spent that way comes back as paper.
Pick the winner on purpose, then say so in the language of the job. “This move was updated from the dispatch desk” is a product sentence. A field that quietly reverts is a defect. Keep the version the device submitted visible. If that version lost, the next action should be obvious without a phone call.
Commands survive a refresh
Model the work as commands with identity. A queue of intents can be retried after a process dies. An idempotency key keeps a retry from becoming a second gate-in. A half-edited form living in component state dies with the tab, which is a rude moment to die if someone is mid-task in a loud yard.
Sync
- Devicelocal truth
- Queuepending
- Serverthe record
type Command = {
id: string;
name: "trip.started" | "scan.accepted" | "task.completed";
payload: unknown;
enqueuedAt: string;
status: "pending" | "acked" | "rejected";
};The names are the product. trip.started can be explained to the person who uses it. A generic update endpoint cannot, not at the moment the yard and the device disagree and someone has to decide which one to believe.
Say what still works
“Saved on this device” is a feature. A spinner that never ends is a promise broken without an admission. The useful status line uses the domain's own words: trip started, scan queued, task waiting to sync. People can plan a shift around that. They cannot plan around a toast that says network error and then leaves the screen blank.
