Offline-First React Native: Designing a Sync Engine That Survives Bad Networks

Local-first storage, an outbox for writes, cursor-based pulls and explicit conflict rules: how we build field apps that keep working underground and in transit.

DSDilawar Singh 2 min read 2,875 views
Offline-First React Native: Designing a Sync Engine That Survives Bad Networks

Field-service, logistics and retail apps cannot assume a connection. Technicians work in basements, drivers pass through dead zones, and store Wi-Fi drops at the worst moment. An offline-first app treats the network as an optimisation: the local database is the source of truth for the UI, and a sync engine reconciles it with the server whenever it can.

Local-first storage

Read and write against an on-device database (SQLite through a library such as op-sqlite or WatermelonDB) rather than an in-memory cache. The UI subscribes to local queries, so it updates instantly whether or not the server has seen the change yet.

Writes go through an outbox

Every mutation is written to the local table and appended to an outbox in the same transaction. Each entry carries a client-generated ID that the server uses as an idempotency key, so retries can never create duplicates.

type OutboxEntry = { id: string; entity: 'job' | 'note'; op: 'upsert' | 'delete'; payload: unknown; createdAt: number; attempts: number };

export async function flushOutbox(api: Api) {
  const pending = await db.outbox.oldestFirst(50);
  for (const entry of pending) {
    try {
      await api.applyMutation(entry);          // server applies idempotently by entry.id
      await db.outbox.remove(entry.id);
    } catch (err) {
      await db.outbox.bumpAttempts(entry.id);
      if (isRetryable(err)) break;             // keep order; retry on the next connectivity event
      await db.outbox.moveToDeadLetter(entry.id, String(err));
    }
  }
}

Pull with a stable cursor

Fetch server changes since the last cursor. A timestamp alone is not enough — two records can share one — so use (updatedAt, id) as the cursor and sort by both.

// Server: changes after the client's cursor, in a stable order
const changes = await Job.find({
  tenantId,
  $or: [{ updatedAt: { $gt: since.at } }, { updatedAt: since.at, _id: { $gt: since.id } }],
}).sort({ updatedAt: 1, _id: 1 }).limit(500).lean();

Deletes are sent as tombstones (a deletedAt field) so offline devices learn about them; purge tombstones only after every device has synced past them.

Decide conflict rules per field

There is no universal conflict strategy. Write the rule down for each entity:

  • Server-authoritative: prices, permissions, assignment — the server value always wins.
  • Last-writer-wins per field: notes, checklists — merge field by field using timestamps from a trusted clock.
  • Append-only: activity logs and photos — never conflict; both sides keep everything.

Trigger sync sensibly

Sync on app foreground, on connectivity regained (via NetInfo) and after local writes with a short debounce. Background execution on iOS is opportunistic, so never rely on it for correctness — only for freshness.

Field note: show sync state honestly in the UI. A small "3 changes waiting to upload" indicator prevents more support tickets than any retry algorithm.

Key takeaways

  • Make the local database the UI's source of truth.
  • Queue writes in an outbox with client-generated idempotency keys.
  • Pull with a compound (updatedAt, id) cursor and propagate deletes as tombstones.
  • Define conflict rules per entity and field.
  • Surface sync status to users.
DS

Written by

Dilawar Singh

Chief Technology Officer, DEV3 Technologies

DEV3 Technologies has delivered web platforms, AI systems, SaaS products and mobile apps since 2025. Articles are written and reviewed by the engineers who build and operate these systems.

Keep reading

All articles