Offline-first is a data model decision, not a caching feature
Most apps bolt offline support on at the end and discover it was an architecture question all along. Here is what actually has to change, and the conflict rules you have to choose between.
"Make it work offline" sounds like a feature you can add in a sprint. It is not. It changes where your data lives and who decides what is true, which touches everything.
The good news is that the work is well understood. The bad news is that retrofitting it costs several times what building it in costs.
Two architectures
The default architecture is server-authoritative. The screen reflects the server. An action sends a request, waits, and updates on the response. Offline means broken, because there is nothing to show and nowhere to put the write.
The alternative is local-first. The device has its own database and that is what the UI reads. Writes go to local storage immediately and the screen updates from there. A background process reconciles with the server when a connection exists.
The inversion is the whole thing: the network stops being in the path between the user and their data. Everything else follows.
What actually changes
Every record needs identity you can generate offline
If the server assigns IDs, a device cannot create anything offline without inventing a temporary ID and rewriting every reference later. Generate IDs on the client, with UUIDs or similar, from day one. This one decision is cheap upfront and painful to retrofit.
Writes become a queue, not a call
An action appends an intent to a durable local queue and returns immediately. A sync process drains it. Which means the queue must survive app termination, retry with backoff, and be idempotent, because you will send some operations twice.
Deletes stop being deletes
If a device deletes a row while offline, and sync only sends what exists, the server never hears about it and helpfully sends it back. You need tombstones: deletion recorded as a fact with a timestamp, kept long enough for every device to see it.
The UI has to show sync state
Once a write can succeed locally and fail later, users need to know. Not a spinner on everything, but an honest indicator: saved locally, syncing, synced, failed. Hiding this produces the worst outcome, which is someone believing their work is safe when it is not.
Choosing a conflict rule
Two devices edit the same record while offline. Both come back. Something has to decide. There is no universally correct answer; there is a right answer for your data.
| Strategy | How it works | Good for |
|---|---|---|
| Last write wins | Highest timestamp overwrites | Settings, per-device state, low-stakes fields |
| Field-level merge | Per-field timestamps; merge non-conflicting fields | Records where people edit different parts |
| Operation log | Store intents, replay in order | Counters, lists, anything additive |
| CRDTs | Structures that merge deterministically | Collaborative text and shared documents |
| Ask the user | Present both, let them choose | Rare, high-value conflicts only |
Two warnings from experience. Last write wins with device clocks silently loses data, because device clocks are wrong; use server-assigned or logical clocks. And "ask the user" is not a default; it is an escape hatch for cases too costly to resolve automatically. Prompted often enough, people click through without reading.
Pick the conflict rule per data type, not per app. A user's flashcard progress can be additive and merge cleanly. Their display name can be last-write-wins. The same rule for both is wrong for one of them.
Sync itself
The workable shape for most products:
- Each record carries a version or updated-at stamp assigned by the server.
- The client stores a cursor: the point it has caught up to.
- On sync, the client sends queued writes and its cursor.
- The server applies the writes, resolves conflicts, and returns everything changed since the cursor plus a new cursor.
- The client applies the changes and advances the cursor atomically.
The atomicity in step 5 matters more than it looks. If you advance the cursor and then fail while applying, you have silently skipped changes and nothing will ever tell you.
Test the ugly cases deliberately
Offline bugs do not appear in normal testing, because normal testing has wifi. Build these into your test plan:
- Airplane mode for a week, then reconnect with hundreds of queued operations.
- Killed mid-sync. Force-quit while a batch is in flight. Nothing may be lost or applied twice.
- Captive portals. The worst network state: the device reports connectivity, every request returns a login page. Requests must fail cleanly, not corrupt state.
- Clock skew. Set the device clock a day out and confirm ordering still holds.
- Two devices, same account, both offline, editing the same records. This is where conflict rules earn their keep.
When not to do this
Offline-first is real work, and it is not always warranted. Skip it when the app is meaningless without the network anyway, such as live chat or payments; when data is inherently shared and simultaneous, where you likely want real-time rather than offline; or when it is a lightly used internal tool where the cost outweighs the benefit.
Do it when people use the app somewhere connectivity is unreliable: commutes, transit, planes, rural areas, buildings with bad signal. For a learning app that is most of the sessions, which is why we treat it as a starting assumption rather than a feature request.
The cheapest version
If a full local-first architecture is too much for the stage you are at, there is a large middle ground worth taking:
- Cache reads aggressively so the app opens to content rather than a spinner.
- Queue writes durably even without full sync, so an action is never lost to a dropped connection.
- Generate IDs client-side now, because it costs nothing today and unblocks everything later.
Those three get most of the perceived benefit for a fraction of the work, and none of them are wasted if you go further later.