Building Offline-First Mobile Apps That Sync Reliably on Flaky Networks
A field agent logs a client visit in rural Ashanti. The app crashes mid-sync. She reopens it two hours later under a better signal — and the record is gone. That single failure erodes trust faster than any competitor ever could.
This is not an edge case. Across much of Africa, mobile internet oscillates between 2G, patchy 4G, and nothing at all. Building for "offline-first" is not a nice-to-have; it is table stakes for any mobile product that expects real-world usage beyond a few major city centers.
The problem is that most sync tutorials are written with a broadband connection quietly humming in the background. They gloss over the hard parts: what happens when two users edit the same record offline, when a sync payload is too large for a 2G pipe, or when a partial sync leaves the local database in an inconsistent state. This article addresses exactly those hard parts.
What "Offline-First" Actually Means
Offline-first is an architectural commitment, not a feature flag. It means the local database is the source of truth for the UI at all times. Network calls are background operations that reconcile local state with a remote server — they never block the user.
The distinction matters because many apps claim offline support but are really just "cached reads with optimistic UI." The moment a write fails and the app has no recovery strategy, the illusion collapses.
A genuine offline-first architecture has three non-negotiable properties:
- Reads are always local. The UI never waits on the network to display data.
- Writes are queued, not dropped. Every mutation is persisted locally first and synced later.
- Conflicts are resolved deterministically. When two versions of a record diverge, the app has a defined policy — not an unhandled exception.
Choosing the Right Local Database
For React Native, WatermelonDB is the most production-ready choice for offline-first workloads. It uses SQLite under the hood with a lazy-loading architecture that keeps the JS thread unblocked. Its built-in sync protocol (synchronize()) expects a structured server API that returns created, updated, and deleted record sets per collection — a delta sync pattern by design.
A minimal WatermelonDB sync call looks like this:
await synchronize({
database,
pullChanges: async ({ lastPulledAt }) => {
const { data } = await api.get('/sync/pull', { params: { lastPulledAt } });
return data; // { changes: { visits: { created, updated, deleted } }, timestamp }
},
pushChanges: async ({ changes, lastPulledAt }) => {
await api.post('/sync/push', { changes, lastPulledAt });
},
migrationsEnabledAtVersion: 1,
});
The lastPulledAt timestamp is the engine of delta sync — you only transfer records that changed since the last successful pull, which is critical on low-bandwidth connections where sending full payloads on every sync would be prohibitive.
Delta Sync: Only Move What Changed
Full-table sync is the enemy of low-bandwidth users. A 50 KB payload might feel trivial in Accra on fiber, but on a 2G connection averaging 50–100 Kbps with high latency, that same payload can time out entirely.
Delta sync solves this by anchoring every pull request to a server-side timestamp or logical clock. The server returns only records modified after that point. On the server side, this means:
- Every table needs an
updated_atcolumn indexed for fast range queries. - Soft deletes (
deleted_at) rather than hard deletes, so the client learns about removed records. - A monotonically increasing server timestamp returned with every pull response, stored locally for the next cycle.
For very high-frequency data (GPS tracks, sensor readings), consider batching writes client-side before pushing — accumulate N records or wait T seconds, whichever comes first.
Conflict Resolution Without Tears
When a field officer edits a record offline and a supervisor edits the same record from a web dashboard simultaneously, you have a conflict. There are three practical strategies:
1. Last-Write-Wins (LWW)
The record with the most recent updated_at timestamp wins. Simple to implement, but dangerous if clocks are skewed — which they often are on devices that haven't synced system time recently.
2. Server-Wins / Client-Wins
A blanket policy: the server always wins, or the client always wins. Predictable, but blunt. Use it only when business logic clearly dictates one authority.
3. CRDTs (Conflict-free Replicated Data Types)
CRDTs are data structures mathematically guaranteed to merge without conflicts, regardless of the order operations are applied. A G-Counter (grow-only counter) or LWW-Register at the field level — rather than the record level — gives you surgical merge behavior.
For example, a survey form where different users might fill in different sections can merge cleanly if each field is treated as an independent LWW-Register. Libraries like Automerge and Yjs bring CRDT primitives to JavaScript environments and are increasingly viable in React Native.
CRDTs do carry an overhead: data structures are larger, and your server must understand the CRDT merge semantics. For most business apps, field-level LWW with a human-readable conflict log is a pragmatic middle ground that avoids both the fragility of pure LWW and the complexity of full CRDTs.
Handling the Sync Queue Reliably
A sync queue is only as good as its persistence. If the queue lives in memory, a process kill wipes pending mutations. The queue must be stored in the local database alongside the records it modifies.
Key patterns for a robust queue:
- Idempotency keys. Every mutation in the queue gets a UUID. The server ignores duplicate submissions with the same key, making retries safe.
- Exponential backoff. On failure, retry after 2s, 4s, 8s… capped at a sensible maximum. Hammering a recovering server compounds the problem.
- Conflict detection at push time. The server compares the client's
lastPulledAtagainst the record'supdated_at. If the server version is newer, reject the push and return the current server record so the client can reconcile.
Testing Offline Behavior
Emulating bad networks is non-negotiable before shipping. In React Native development, use Android Emulator's network throttling or the Network Link Conditioner on iOS to simulate 2G and high-loss scenarios. Write integration tests that forcibly interrupt sync mid-operation and assert the local database remains consistent.
Why This Matters for Your Project
If your mobile product serves users across Ghana, Nigeria, Kenya, or anywhere with variable connectivity, offline-first architecture is a direct revenue decision. Apps that work in the field get used; apps that require a signal get abandoned. Investing in a proper local database, a delta sync protocol, and a conflict resolution policy early in the project is far cheaper than retrofitting reliability after launch — and it is the difference between software that earns trust and software that quietly loses it.




