MD. Shahadot Hossain
Back to Knowledge Hub
Oct 11, 20266 min read

Offline-First Sync for React Native

Outbox, cursors, idempotency and conflict resolution for apps that must work without a connection.

react-nativeoffline-firstsyncarchitecture

Field and enterprise apps cannot assume a good connection. An offline-first app treats the network as an optimisation: the local database is the source of truth the user sees, and a sync engine reconciles it with the server in the background. This guide covers the architecture, the data you need to track, conflict handling, and how to test it, using React Native and TypeScript examples.

Why offline-first

  • Users work in basements, warehouses, remote areas and on poor mobile data.
  • The app feels instant because every read and write hits local storage.
  • Lost connectivity stops being an error and becomes a normal state.

The price is complexity: you must decide how changes travel, what happens when two people edit the same record, and how to prove nothing was lost.

Architecture

The UI never talks to the network. It reads and writes through a repository, which updates the local database and records the change in an outbox. A separate sync engine pushes the outbox and pulls remote changes.

Choosing local storage

OptionBest forNotes
MMKVSmall key-value data, flags, tokens, cachesVery fast, synchronous, not relational
AsyncStorageTiny, simple persistenceSlower, async, avoid for large data
SQLite (for example via expo-sqlite)Relational data and queriesFull control, you write the sync layer
WatermelonDBLarge relational datasets with reactive queriesHas built-in sync primitives

Use MMKV for settings and the sync cursor, and a relational store for business records.

What every synced record needs

interface SyncedRecord {
  id: string;            // client-generated UUID, so it can be created offline
  updatedAt: number;     // last modified, set by the server on accept
  version: number;       // incremented on every accepted change
  deletedAt: number | null; // tombstone instead of hard delete
}

Client-generated ids mean you can create records offline and link children to them before the server has ever heard of them. Tombstones let a delete travel to other devices.

The outbox

Every local write also appends a mutation to the outbox. A mutation is a small description of intent, not a copy of the whole row.

interface Mutation {
  id: string;            // idempotency key, unique per mutation
  entity: "workOrder";
  entityId: string;
  type: "create" | "update" | "delete";
  patch: Record<string, unknown>;
  baseVersion: number;   // the version this change was made against
  createdAt: number;
  attempts: number;
}

async function saveWorkOrder(id: string, patch: Partial<WorkOrder>) {
  await db.transaction(async () => {
    await workOrders.update(id, patch);          // local write, UI updates now
    await outbox.add({
      id: uuid(),
      entity: "workOrder",
      entityId: id,
      type: "update",
      patch,
      baseVersion: (await workOrders.get(id)).version,
      createdAt: Date.now(),
      attempts: 0,
    });
  });
  syncSoon();
}

The local write and the outbox entry must commit in one transaction. Otherwise a crash can leave a change that is saved but never sent.

Sync: push then pull

export async function syncOnce() {
  const pending = await outbox.nextBatch(50);

  if (pending.length) {
    const res = await api.push(pending); // server applies each mutation at most once
    await outbox.markDone(res.accepted);
    await handleConflicts(res.conflicts);
    await outbox.recordFailure(res.rejected);
  }

  let cursor = await storage.getCursor();
  while (true) {
    const page = await api.pull(cursor);
    await db.applyRemoteChanges(page.changes); // inside one transaction
    cursor = page.cursor;
    await storage.setCursor(cursor);
    if (!page.hasMore) break;
  }
}

Rules that keep this correct:

  • Idempotency: the server remembers mutation ids it already applied and ignores repeats, so retries are safe.
  • Cursors, not timestamps: the pull cursor is an opaque server value. Device clocks drift, so never use the phone's clock to decide what is newer.
  • Batches: send and fetch in pages so a bad connection does not lose everything.
  • Apply atomically: apply a page of remote changes in one transaction and save the cursor in the same step.

Handling conflicts

A conflict occurs when two devices change the same record from the same starting version. Choose a strategy per entity, not once for the whole app.

StrategyHow it worksGood forRisk
Last write winsNewest accepted change overwritesLow-value fields like a "last seen" flagSilently loses edits
Field-level mergeMerge non-overlapping field changes, conflict only on the same fieldMost forms and recordsMore logic
Server rulesServer decides by business logic, for example status can only move forwardWorkflows and state machinesNeeds clear rules
Manual resolutionShow both versions and let the user pickHigh-value data, legal or financial fieldsInterrupts the user
function mergeFields(base: Rec, local: Rec, remote: Rec) {
  const merged = { ...remote };
  const conflicts: string[] = [];
  for (const key of Object.keys(local)) {
    const localChanged = local[key] !== base[key];
    const remoteChanged = remote[key] !== base[key];
    if (localChanged && !remoteChanged) merged[key] = local[key];
    else if (localChanged && remoteChanged && local[key] !== remote[key]) conflicts.push(key);
  }
  return { merged, conflicts };
}

Whatever you choose, never drop a change without telling someone. Log every automatic resolution.

Files and photos

Binary uploads follow the same idea, with a queue of pending uploads:

  • Save the file locally first and reference it by a local path.
  • Upload in the background, resumable if possible, then record the remote URL.
  • Compress before upload and enforce size limits.
  • Keep the record usable while the upload is pending.

Background sync

Syncing only while the app is open leaves data stranded on the device. Add:

  • A connectivity listener that triggers sync when the network returns.
  • Periodic background work using the platform's task APIs (in Expo, expo-task-manager and the current background task module; check the docs for your SDK version, as these APIs have changed).
  • Exponential backoff with a cap, so a failing server is not hammered.
  • Sensible limits on battery, data use and frequency.

Background execution is best effort. The OS decides when it runs, so the app must also sync when it opens.

Security

  • Store tokens in secure storage, not in plain key-value storage.
  • Encrypt sensitive local data and wipe it on logout or device removal.
  • Authorise every pushed mutation on the server. Never trust the client.
  • Handle revoked access: a user removed from a job must lose that data on the next sync.

Show the user what is happening

  • A small sync indicator: synced, syncing, offline, needs attention.
  • A count of pending changes.
  • Clear errors with a retry action, and a visible list of conflicts to resolve.

Test the hard cases

  • Airplane mode for a whole workflow, then reconnect.
  • Kill the app mid-sync and restart.
  • Two devices edit the same record offline.
  • Duplicate pushes and replayed responses.
  • Large backlogs after days offline.
  • Clock changes on the device.
  • Property-based tests that apply random sequences of offline edits and assert that all devices converge.

Metrics to watch

Sync lag, pending outbox size, failed push rate, conflict rate, payload size and time to first sync after install. These tell you about problems before users do.

Common mistakes

  • Writing to the UI state and the database separately, so they drift.
  • Using device timestamps to order changes.
  • Hard deletes that never reach other devices.
  • No idempotency, so a retry duplicates data.
  • Treating "last write wins" as a safe default for everything.
  • Testing only on a fast connection.

Checklist

  • Local database is the source of truth for the UI
  • Client-generated ids and tombstones
  • Local write and outbox entry in one transaction
  • Idempotent server push, cursor-based pull
  • Conflict strategy chosen per entity and logged
  • Background sync with backoff, plus sync on app open
  • Sync status visible to the user
  • Offline, crash and two-device tests automated