Offline-first React Native: the sync is the hard part

A fitness app I worked on had a specific problem: people use gyms in basements. No signal, right at the moment they want to log a set.
“Make it work offline” sounds like a storage task. Storage is the easy part. The hard part is what happens when the phone comes back online holding changes the server has never seen, and the server holds changes the phone has never seen.
Local first, always
The rule that makes everything else fall into place: the UI never waits for the network. Every write goes to local storage, the UI updates from local storage, and syncing happens somewhere else entirely.
This is not the same as optimistic updates. Optimistic UI shows a change and reconciles when the request returns — still fundamentally network-shaped, with a spinner hiding somewhere. Local-first means the local database is the source of truth for the UI, and the network is a background process that happens to reconcile it.
I used WatermelonDB on that build. SQLite underneath, observable queries, built for exactly this. MMKV works for smaller state. AsyncStorage does not — it is a key-value store with no query capability and it will not survive contact with a real dataset.
The outbox
Every mutation appends to a local queue before it touches the network:
await db.write(async () => {
const set = await sets.create(s => {
s.exerciseId = exerciseId;
s.reps = reps;
s.weight = weight;
s.clientId = uuid(); // client-generated, stable across retries
s.loggedAt = Date.now();
});
await outbox.create(o => {
o.op = 'create_set';
o.payload = JSON.stringify(set);
});
});
Client-generated ids matter more than they look. When a request times out but actually succeeded, the retry carries the same clientId, the server recognises it and does not create a second record. Same idempotency reasoning as webhooks, different direction.
Conflict resolution is a product decision
This is where I see teams stall, because they treat it as a technical question with a correct answer. It is not.
Last-write-wins is the default and it is fine for a lot of data. It is not fine when losing the write is expensive. For workout logs it was fine — nobody edits the same set from two devices. For the user’s training plan, which a coach could also edit from a web dashboard, it was not.
What we settled on, per data type:
- Append-only records (logged sets, completed sessions) — no conflicts possible. Design as much as you can into this shape.
- User-owned settings — last-write-wins on a per-field basis, not per-record. Two devices changing different fields should both win.
- Shared documents (training plans) — server wins, and the client is told. The user sees “your coach updated this plan” rather than losing edits silently.
Per-field merging rather than per-record is worth the extra work. Whole-record LWW means changing your notification preference on one device silently reverts the unit setting you changed on another.
Connectivity is not binary
NetInfo.isConnected tells you there is an interface. It does not tell you anything useful.
Gym wifi that requires a captive login reports as connected. So does a connection dropping to 20kbps. So does a mobile network in a lift. The state you actually care about is “can I reach my API right now”, and the only way to know is to try.
Sync attempts on a timer with exponential backoff, treating any failure the same regardless of what NetInfo claims. Connectivity events are a hint to try sooner, never a precondition.
Test it properly
Airplane mode is the easy case and the one that always works. The failures live in the messy states:
- Request sent, response never arrives. Did it apply?
- Sync interrupted halfway through a batch.
- App killed by the OS mid-write.
- Clock skew — a device several minutes off, poisoning timestamp-based resolution.
That last one caused a bug I chased for two days. A user’s phone was eleven minutes fast, so their edits always won last-write-wins, including over edits made after them. Server timestamps for ordering, client timestamps only for display.