Offline-First React Native: The Sync Architecture That Survives Real Networks
How to build a mobile app that works on a train, in a basement, and on 2G — local-first writes, an outbox queue, conflict resolution, and the mistakes that quietly corrupt user data.
Mobile networks are not slow web networks
Web engineers building their first mobile app usually assume mobile means "the same requests, but slower". It does not. Mobile means:
A loading spinner is an acceptable answer on the web. On mobile it is how you get one-star reviews from people on a commuter train.
The fix is not "add a retry". It is to stop treating the server as the source of truth for the UI.
The core idea
Local writes are instant and authoritative for the UI. The network is a background sync process the user never waits on.
Three pieces:
The user taps, the local DB updates, the screen updates in the same frame. Whether the request succeeded is a separate concern.
Picking the local store
| Option | Good for | Watch out |
|---|---|---|
| SQLite (expo-sqlite / op-sqlite) | Relational data, real queries | You write the sync layer yourself |
| WatermelonDB | Large datasets, lazy loading | Opinionated schema and sync protocol |
| MMKV | Key-value, settings, tokens | No querying — not a database |
| AsyncStorage | Nothing serious | Slow, unbounded, easy to corrupt |
For most client apps I reach for SQLite with Drizzle on top. You get typed queries, real migrations, and no framework deciding your sync protocol for you.
Do not use AsyncStorage as a database. It is effectively a single JSON blob under load, it has no transactions, and a process kill mid-write can lose the whole store.
The outbox
Every mutation is a durable row before it is a network request:
type Op = {
id: string; // uuid generated on device
entity: "task";
type: "create" | "update" | "delete";
payload: unknown;
createdAt: number; // device clock, for local ordering only
attempts: number;
};The flow for any user action:
The transaction matters. If you write the entity but the process dies before the outbox row lands, that change never reaches the server and the user's data silently diverges. One transaction, both writes, or neither.
Idempotency is not optional
The device generates the ID, not the server. This one decision removes an entire category of bug:
Your server endpoint must be idempotent on that ID. Send it as an Idempotency-Key header too if the API supports it.
Draining the queue
async function drain() {
const ops = await db.select().from(outbox).orderBy(outbox.createdAt).limit(20);
for (const op of ops) {
try {
await api.apply(op); // idempotent server-side
await db.delete(outbox).where(eq(outbox.id, op.id));
} catch (e) {
if (isPermanent(e)) { // 4xx that a retry will not fix
await moveToDeadLetter(op);
continue;
}
await bumpAttempts(op); // 5xx / network — retry later
return; // stop; preserve order
}
}
}Three rules are baked into that loop:
When to sync
Trigger the drain on:
@react-native-community/netinfo)Do not poll on a timer while the app is in the foreground. It burns battery and it is the reason your app shows up in the OS battery report, which is the reason users delete it.
Also: netinfo reporting a connection does not mean the internet works. Captive portals in hotels and airports return HTTP 200 for everything. Treat your own API's response as the real signal.
Pulling changes down
Use cursor-based delta sync, not full refetches:
GET /sync?since=<server_cursor>
-> { changes: [...], cursor: "<new_cursor>", hasMore: false }The cursor comes from the server and is opaque to the client. Never use the device clock as the sync cursor — device clocks are wrong, sometimes by hours, sometimes deliberately. A user changing their timezone should not silently skip a day of changes.
Loop until hasMore is false, persisting the cursor after each page, so an interrupted sync resumes instead of restarting.
Conflicts
Something will be edited in two places. Pick your policy explicitly:
For most client apps, field-level merge on a small set of user-editable fields, with last-write-wins as the fallback, hits the right balance. The failure mode to avoid at all costs is a full-object overwrite that silently reverts a field the user changed thirty seconds ago on their phone.
Showing state honestly
Users forgive offline. They do not forgive lying.
The mistakes that actually corrupt data
Every one of those I have either shipped or inherited.
Is it worth it?
If your app is a thin client over a dashboard that people use at a desk on wifi — probably not. Cache aggressively and move on.
If people use your app in the field, in a warehouse, on a job site, in a vehicle, or anywhere with real network conditions, offline-first is not a feature. It is the difference between an app that works and an app they stop opening.
You might also like
Shipping a React Native App in 2026: The Expo Production Checklist
Everything between a working simulator build and a live listing on both stores — EAS builds, OTA updates, signing, permissions, crash reporting, and the review rejections that cost me weeks.
Rate Limiting APIs in 2026: Algorithms, Keys, and Headers That Actually Work
Token bucket vs sliding window, where to enforce limits, what to key on, and how to return limits clients can respect — with an atomic Redis implementation.
Working Under NDA as a Freelance Engineer: What You Can Show and What You Cannot
Some of my best work is client-confidential. How I present NDA projects in a portfolio, what to negotiate before signing, and how to keep proof of work without leaking anything.