Design Offline-First Mobile Sync That Survives Bad Networks
Queued mutations, idempotency, and last-write-wins versus CRDTs so your app keeps working in a tunnel and reconciles cleanly later.
A mobile app that only works on a strong connection is a desktop app that happens to run on a phone. Real phones go through tunnels, dead zones, packed stadiums, and basement parking garages, and the apps people actually keep are the ones that keep working when the bars drop to zero. The user taps, the thing happens, and the syncing with the server is the app's problem to sort out later, quietly, without ever putting an error in front of the person who just wanted to mark a task done.
Building that well is mostly about three decisions: where the source of truth lives while offline, how you queue changes that cannot be sent yet, and what happens when the same record was edited in two places before they could reconcile. Get those right and bad networks become invisible. Get them wrong and you get the worst failure mode in mobile, silent data loss, where a user's edit vanishes and they do not find out until it matters.
Local-first: the device holds the truth until it can sync
The foundational shift is that the local database is the source of truth for the user's session, not a cache of the server. When the user makes a change, you write it locally and update the UI immediately, optimistically, as if the change already succeeded. You do not wait for a network round trip to show the result, because the network might not be there, and even when it is, waiting makes the app feel sluggish.
This is optimistic update, and it is what makes an offline-first app feel instant. The user marks an item complete, it shows complete now, and the job of getting that fact to the server happens in the background on its own schedule, the same instinct behind making slow feel fast with optimistic UI and smart skeletons. The local store is authoritative for what the user sees; the server is where it eventually agrees with everyone else's view.
The practical stack on Android tends to be a local database (Room) for the durable store, plus a background sync mechanism (WorkManager) that survives the app being backgrounded and retries when conditions allow, plus network-state awareness so sync fires when connectivity returns. iOS has the equivalent pieces, and shipping the same logic to both quickly without forking the codebase is the case for shipping React Native updates the same day you write them. The shapes differ, the architecture is the same: write local, queue the sync, reconcile when you can.
Queue mutations, never block the user
When a change cannot be sent because there is no connection, you do not show an error and you do not block. You append the change to a durable queue of pending mutations and let the user keep going. When connectivity returns, you drain the queue in order, sending each pending change to the server.
The queue has to be durable, meaning it survives the app being killed and the phone being restarted. A queue held only in memory loses everything if the user closes the app in the elevator, which is exactly when they are most likely to be offline. Persist it to the local database so a phone that dies mid-tunnel comes back, reconnects, and finishes syncing the work the user did while disconnected.
Two efficiencies matter once the queue works:
- Delta sync, not full sync. When you reconnect, send only what changed since the last successful sync, not the entire dataset. On a flaky or metered connection, pushing only the deltas is the difference between a sync that completes and one that times out repeatedly.
- Idempotent operations. Network retries mean the same mutation may be sent more than once. Give each mutation a client-generated ID so the server can recognise a duplicate and ignore it, rather than applying the same "add $50" twice. A retry that double-applies is its own kind of data corruption, the same hazard that makes you keep agent tool calls idempotent before they double-charge a customer.
The hard part: conflict resolution
The genuinely difficult problem is what happens when the same record changed in two places before they synced. The user edited a note on their phone offline, then edited the same note in the web app, and now there are two versions with no obvious winner. There is no single right answer, only a set of strategies with different trade-offs, and choosing the right one per data type is the actual engineering.
Last-write-wins is the simplest. Each write carries a timestamp, and the latest timestamp wins. It is easy to implement and acceptable for data where losing one of two concurrent edits is tolerable, like a user preference or a status flag. It is risky for anything where both edits carry real information, because it silently discards one of them. The user who typed a paragraph offline, only to have it overwritten by a one-word edit made elsewhere a second later, experienced data loss, and last-write-wins is how it happened.
CRDTs (Conflict-free Replicated Data Types) are the heavier, stronger tool. A CRDT models the data as a series of operations that are mathematically guaranteed to merge to the same final state on every device, regardless of the order or timing of sync. Two people editing the same list offline both get their changes, merged deterministically, with no central authority deciding who was right. For collaborative editing and shared lists, this is the architecture that avoids losing anyone's work.
The trap is thinking CRDTs solve everything. They guarantee the underlying data merges without conflict, but they do not know what "correct" means for your business. A CRDT will happily merge two edits into a state that is structurally valid and semantically wrong for your app, like a workflow that skipped a required step because the operations merged out of causal order. So in practice you layer business logic on top: a CRDT guarantees the data merges cleanly, and your application logic, often a state machine that respects causal dependencies, defines what a valid merged state actually is. The CRDT handles the mechanics of merging; you still own the meaning.
For many apps, a middle path is right: last-write-wins for low-stakes fields, a three-way merge or user-assisted resolution for important documents (show both versions, let the user pick or merge), and CRDTs only for the genuinely collaborative surfaces where concurrent editing is a core feature. Matching the strategy to the data, rather than picking one for the whole app, is what keeps the implementation sane.
What "survives bad networks" actually requires
Pulling it together, an offline-first app that holds up in a tunnel and reconciles cleanly afterward needs:
- A durable local store that is the source of truth during the session.
- Optimistic UI so every action feels instant whether or not the network is there.
- A persistent mutation queue that survives app kills and replays in order on reconnect.
- Idempotency on every mutation so retries never double-apply.
- Delta sync so reconnection over a weak link actually completes.
- A conflict strategy chosen per data type, defaulting to never silently losing a user's real work.
- Honest sync-state indicators, so a user can tell the difference between "saved locally, will sync" and "synced everywhere," without being made anxious about it.
That last point is a UX decision as much as a technical one. The user should be able to trust that what they typed is safe even before it reaches the server, and the app should communicate that quietly. An app that throws connection errors at every offline action trains the user to distrust it; one that absorbs the offline period and catches up invisibly earns the opposite, which is also why error messages that recover trust instead of losing customers matter for the rare failure you do surface. The same WebKit constraints that complicate offline writes are covered in building a PWA that feels native on iOS despite Safari's limits.
When the sync server itself has to push live updates back to many connected devices, you are into the territory of scaling realtime WebSocket features without drowning in backpressure. This is exactly the kind of thing that separates a mobile app people rely on in the field from a demo that only works on office Wi-Fi. The offline story is not a feature you add at the end, it is an architecture you choose at the start, because retrofitting optimistic updates and a durable queue onto an app that assumed connectivity is most of a rewrite. If your app falls over the moment the signal drops, or worse, quietly loses work when it comes back, that is a fixable problem, and it is one of the more satisfying ones to get right.






