Offline-first is a data model decision, not a caching trick
Teams reach for a cache and discover six months later that their real problem was never storage. It was deciding who wins when two people edit the same thing.
Every app used away from a desk eventually meets the same wall: a basement with no signal, a site with one bar, a flight, a rural road. The usual first response is to add a cache so the last-loaded screen still renders. That fixes reading. It does nothing for the actual problem, which is writing.
The moment a user can create or change something offline, you have a distributed system. Two devices can hold different versions of the same record, both believing they're correct, and at some point they both come back online. Everything hard about offline-first lives in that sentence.
Decide your conflict rule before you write any sync code
There are only a few honest answers, and the right one depends on the data, not on fashion:
- Last-write-wins — simple, and fine for fields only one person realistically touches. Silently destroys data when that assumption is wrong.
- Append-only log — don't update records at all; record events and derive state. Conflicts mostly stop existing because two events can both be true.
- Field-level merge — resolve per field rather than per record, so two people editing different fields of the same record don't collide.
- CRDTs — mathematically guaranteed convergence, real complexity cost. Worth it for genuine collaborative editing, overkill for a form.
- Explicit user resolution — show both versions and ask. The only honest option when a wrong merge is expensive.
Most field applications land on a mix: append-only for anything that reads like an event (a reading taken, a photo added, a status changed at a time), last-write-wins for genuinely single-owner fields, and explicit resolution for the small set of records where a bad merge causes real damage.
If you can restructure a record so that updates become appends, do it. An event that happened at a time is not in conflict with another event that happened at another time — the conflict disappears rather than being resolved.
Make every write idempotent from day one
Offline queues retry. Networks drop after the server has committed but before the response arrives. If a retried write creates a second record, your users will find out before your tests do, usually as duplicates in a report.
Generate the record's ID on the device, not on the server. A client-generated UUID makes the write naturally idempotent: the server can upsert on that key, and a replayed request lands on the same row. This one decision removes an entire category of bug, and it's very painful to retrofit once IDs are server-assigned and referenced elsewhere.
// Client-generated id + monotonic local sequence.
// The server upserts on id, so a retry is a no-op rather than a duplicate.
await queue.push({
id: crypto.randomUUID(),
seq: nextLocalSeq(),
entity: "visit_note",
op: "create",
payload,
createdAt: Date.now(),
});The queue needs a poison-message path
A sync queue that retries forever will eventually meet a write the server will never accept — a validation rule changed, a parent record was deleted, the payload was written by an old app version. Without a path for that message, it sits at the head of the queue and blocks everything behind it. The user's symptom is that sync silently stopped working days ago.
- Retry with backoff for transient failures (5xx, timeouts, no connectivity)
- Do not retry deterministic rejections (4xx validation) — they will fail identically forever
- Move failed messages to a dead-letter store after a bounded number of attempts
- Let the rest of the queue continue past a poisoned message
- Surface the failure in the UI, attached to the record the user recognises
That last point gets skipped most often. A sync error in a log nobody reads is the same as no error handling at all. The person who typed the note is the only one who can fix it, and they need to see it on the note.
Tell the truth about sync state in the UI
Users tolerate offline far better than they tolerate uncertainty. The states worth distinguishing are: saved on this device, sent, confirmed by the server, and failed. Collapsing those into a single spinner is what produces the behaviour where someone writes the same note three times because they weren't sure it took.
Show it per record, not just globally. A banner saying 'offline' tells the user nothing about whether the specific thing they just did is safe.
Test on the network you actually have
Airplane mode is the easy case — the app knows it's offline and behaves accordingly. The case that breaks software is one bar: requests that connect and then hang, DNS that resolves slowly, a captive portal returning HTML with a 200 status code to every request.
Set aggressive client-side timeouts and treat a hang as offline rather than waiting on the platform default. Then test with a throttling proxy that can drop connections mid-flight, because that is the condition your users are actually in when they complain that the app is frozen.