Offline-First React Native: The Sync Architecture That Survives Real Networks
React NativeMobileArchitecture

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.

HJ
Hassan Javed
August 2026
10 min read

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:

The connection disappears mid-request, regularly
The OS kills your process while a request is in flight
The device is offline for hours and then online for ten seconds
The clock on the device may be wrong

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:

1.A local database the UI reads from — the only thing the UI ever reads from
2.An outbox of pending mutations, persisted to disk
3.A sync engine that drains the outbox and pulls remote changes when a connection exists

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

OptionGood forWatch out
SQLite (expo-sqlite / op-sqlite)Relational data, real queriesYou write the sync layer yourself
WatermelonDBLarge datasets, lazy loadingOpinionated schema and sync protocol
MMKVKey-value, settings, tokensNo querying — not a database
AsyncStorageNothing seriousSlow, 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:

tscode
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:

1.Write the change to the local table and insert an outbox row, in one transaction
2.Return — the UI is already correct
3.The sync engine picks the outbox up whenever it can

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:

Retries cannot create duplicates — the server upserts on your ID
The UI has a stable key immediately, so no flicker when a real ID arrives
Related records can be created offline and reference each other before either exists remotely

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

tscode
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:

Process in order and stop on the first retryable failure. Skipping ahead applies an update before the create it depends on.
Distinguish permanent from transient errors. A 422 will fail identically forever; retrying it in a loop drains the battery and never succeeds. Move it to a dead-letter table and surface it in the UI.
Back off exponentially with jitter. When a server comes back after an outage, every device in your userbase reconnects at once. Jitter is what stops your own app from taking you down again.

When to sync

Trigger the drain on:

App foreground
Connectivity regained (@react-native-community/netinfo)
After any local mutation, debounced by ~500ms
A background task, if the platform grants you one

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:

Last-write-wins by server timestamp — simple, fine for single-user apps across their own devices. Data loss is possible but rare and usually invisible.
Field-level merge — track which fields changed locally and only overwrite those. Much better UX for forms; more bookkeeping.
Explicit resolution — keep both versions, ask the user. Only worth it for high-value documents.

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.

A subtle banner when there are unsynced changes — not a modal
A per-item indicator for pending rows
A real error surface for dead-lettered operations, with a retry button
Never a blocking spinner for something that already succeeded locally

The mistakes that actually corrupt data

1.Writing the entity and the outbox row in separate transactions — a process kill between them loses the change permanently.
2.Using device time as a sync cursor or for conflict ordering — wrong clocks silently drop or resurrect data.
3.Retrying permanent failures forever — the queue head jams and nothing behind it ever syncs.
4.Skipping failed operations to keep going — order breaks, updates apply to records that do not exist.
5.No dead-letter path — an operation that can never succeed becomes an invisible permanent stall.
6.Clearing local data on logout without draining the queue first — the user's last few minutes of work disappear.

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.

Related Reads

You might also like