DEV Community

Cover image for Offline-First in React Native: Building an Auto-Sync Engine That Users Never Think About
Shivkrishna Shah
Shivkrishna Shah

Posted on

Offline-First in React Native: Building an Auto-Sync Engine That Users Never Think About

By Shivkrishna Shah · Engineer Philosophy — @shivkrishnashah · @engineerphilosophy

Your app shouldn't have a "no internet" screen. Here's the architecture I use to make mobile apps write locally, sync automatically, and survive the messy reality of field connectivity.

Every mobile developer has shipped this screen at least once: a sad cloud icon and the words "No internet connection. Please try again."

For consumer apps, that's an annoyance. For enterprise field apps — sales reps in hospital basements, auditors in warehouses, technicians in rural areas — it's a dealbreaker. If the app stops working when the signal drops, people stop trusting it. And once field users stop trusting an app, they go back to paper and WhatsApp.

I spent the last few years building and maintaining an offline-first React Native platform used daily by field teams across multiple countries. This post is the architecture I wish someone had handed me on day one: how to structure local storage, detect connectivity, queue writes, auto-sync in the background, and avoid the two bugs that will absolutely bite you (duplicates and conflicts).

Everything here is generic — I'll use Realm DB and NetInfo in the examples, but the pattern maps cleanly onto WatermelonDB, SQLite, or MMKV-backed queues.


The one rule that changes everything

The local database is the source of truth. The server is just a replica you happen to reconcile with.

Most apps are built the other way around: the server is the truth, and the app is a thin cache over fetch(). Offline-first inverts this. Every read comes from the local DB. Every write goes to the local DB first. The network is an implementation detail that a background service worries about — never the UI.

This single inversion gives you three things for free:

  • Zero-latency UX. Saves are instant because they're local writes. No spinners on submit.
  • Airplane-mode parity. The app behaves identically online and offline, because the UI never talks to the network.
  • Crash safety. Data is durable the moment the user taps "Save" — even if the app is killed a second later.
┌──────────────┐   write    ┌───────────────────┐  reconcile  ┌──────────────┐
│  UI/Screens  │ ─────────► │  Local DB (Realm) │ ◄─────────► │  Sync Engine │
│              │ ◄───────── │  SOURCE OF TRUTH  │             │  (background)│
└──────────────┘  live query└───────────────────┘             └──────┬───────┘
                                                                     │ when online
        The UI never touches the network. Ever.               ┌──────▼───────┐
                                              NetInfo ───────►│   REST API   │
                                           (wake / pause)     │   (replica)  │
                                                              └──────────────┘
Enter fullscreen mode Exit fullscreen mode

Step 1 — Give every record a sync passport

Offline-first lives or dies on per-record sync metadata. Every table that can be written on-device carries the same extra fields:

// Every offline-writable schema carries the same sync metadata
const VisitLogSchema = {
  name: 'VisitLog',
  primaryKey: 'localId',
  properties: {
    localId:     'string',   // UUID generated on-device
    serverId:    'int?',     // assigned by the server after first sync
    // ...domain fields (accountId, notes, timestamp, ...)

    syncStatus:  'string',   // 'pending' | 'syncing' | 'synced' | 'failed'
    updatedAt:   'date',     // device clock, for conflict resolution
    syncedAt:    'date?',    // last successful server ack
    retryCount:  'int',      // exponential backoff counter
  },
};
Enter fullscreen mode Exit fullscreen mode

Three deliberate choices here:

  1. Client-generated primary keys (UUIDs). The device must be able to create records — and relate them to each other — without asking the server for an ID. The server ID arrives later and is stored alongside, never as the primary key.
  2. syncStatus is data, not app state. It survives restarts, it's queryable (syncStatus == 'pending' is your sync queue), and you can surface it in the UI as a per-record badge.
  3. retryCount lives on the record. Backoff shouldn't reset because the user relaunched the app.

💡 Design note: You don't need a separate "outbox" table if your DB is queryable — the pending queue is simply a live query over syncStatus IN ('pending','failed') ordered by updatedAt. One source of truth, no queue/table drift.


Step 2 — The write path: local first, always

Every save in the app goes through one door. No screen ever calls the API directly on submit:

export function saveVisitLog(data) {
  const realm = getRealm();
  realm.write(() => {
    realm.create('VisitLog', {
      ...data,
      localId: uuid(),
      syncStatus: 'pending',   // ← queued by definition
      updatedAt: new Date(),
      retryCount: 0,
    });
  });

  requestSync('write');  // nudge the engine — fire and forget
}
Enter fullscreen mode Exit fullscreen mode

Notice what's missing: no await fetch(), no try/catch around a network call, no "are we online?" check. The save is complete the moment the Realm transaction commits. The UI can navigate away immediately and show a PENDING badge on the record.

User taps "Save" ──► Realm write (status: pending) ──┬──► UI updates instantly ✓
                                                     └╌╌► Sync engine nudged ╌╌► Server (eventually)
Enter fullscreen mode Exit fullscreen mode

Step 3 — Connectivity: react to events, verify before flushing

@react-native-community/netinfo gives you connectivity events, but two gotchas matter in production:

  • isConnected means "has a network interface", not "can reach your API". Captive portals and dead corporate Wi-Fi will lie to you. Use isInternetReachable, and treat even that as a hint.
  • Connectivity flaps. Walking through a building can fire a dozen transitions per minute — debounce before triggering a flush.
import NetInfo from '@react-native-community/netinfo';

let online = false;

NetInfo.addEventListener(state => {
  const next = !!state.isConnected && state.isInternetReachable !== false;
  if (next && !online) requestSync('network-restored');  // debounced inside
  online = next;
});
Enter fullscreen mode Exit fullscreen mode

⚠️ Hard-won lesson: Never gate the save on connectivity — only gate the flush. The moment you write if (online) api.post() else saveLocally() you have two write paths, and they will drift apart. One door: local write, then sync.


Step 4 — The sync engine: a state machine, not a loop

The engine is a single background service that drains the pending queue in batches. Every record moves through an explicit lifecycle:

             batch picked            2xx + ack
 ┌─────────┐ ──────────► ┌─────────┐ ──────────► ┌──────────┐
 │ PENDING │             │ SYNCING │             │ SYNCED ✓ │
 └─────────┘ ◄╌╌╌╌╌╌╌╌╌╌ └─────────┘ ──────────► ┌──────────┐
       ▲   retry after        error / timeout    │  FAILED  │
       └╌╌ backoff: 2^retryCount × 30s (capped)  └──────────┘
                                                      │ retryCount > N
                                                      ▼ flag for support
Enter fullscreen mode Exit fullscreen mode
async function flush() {
  if (flushing || !online) return;      // single-flight guard
  flushing = true;
  try {
    const batch = realm.objects('VisitLog')
      .filtered('syncStatus == "pending" AND nextRetryAt <= $0', new Date())
      .sorted('updatedAt')
      .slice(0, BATCH_SIZE);            // e.g. 50 records per request

    if (batch.length === 0) return;

    markStatus(batch, 'syncing');
    const res = await api.post('/sync', { records: serialize(batch) });

    // Server acks per record — never all-or-nothing
    realm.write(() => {
      for (const ack of res.acks) {
        const rec = realm.objectForPrimaryKey('VisitLog', ack.localId);
        if (ack.ok) {
          rec.serverId = ack.serverId;
          rec.syncStatus = 'synced';
          rec.syncedAt = new Date();
        } else {
          rec.syncStatus = 'failed';
          rec.retryCount += 1;
          rec.nextRetryAt = backoff(rec.retryCount);  // 2^n × 30s, capped at 1h
        }
      }
    });

    if (moreRemaining()) requestSync('drain');  // keep draining
  } finally {
    flushing = false;
  }
}
Enter fullscreen mode Exit fullscreen mode

The details that matter more than they look:

  • Single-flight guard. Multiple triggers (timer + network event + fresh write) must never produce two concurrent flushes. One boolean saves you from the nastiest class of duplicate bug.
  • Batching. Field users come back from a no-signal day with hundreds of pending records. One request per record melts your server and their battery; one giant request times out on 2G. Batch (~50) and drain iteratively.
  • Per-record acks. If record 37 of 50 fails validation, the other 49 must still succeed. All-or-nothing batches turn one bad record into a permanently stuck queue.

Step 5 — Auto-sync: triggers, not polling

"Auto" sync is just wiring the same requestSync() nudge to every moment connectivity or data might have changed:

Trigger Source Why it matters
After every local write saveX() helpers Online users sync within seconds — feels real-time
Network restored NetInfo listener (debounced) The classic "walked out of the basement" moment
App → foreground AppState listener OS may have suspended timers while backgrounded
Periodic timer ~every 2–5 min while active Safety net for missed events; also pulls server changes down
Manual pull-to-refresh User Trust: users want a button even if they never need it

All five funnel into one debounced entry point — which is exactly why the single-flight guard in Step 4 exists.


Step 6 — The duplicate problem (this one will get you)

Here's the failure sequence that produces duplicate records in every naive sync implementation:

  1. Device sends batch → server inserts the rows…
  2. …but the response is lost (timeout, tunnel, app killed mid-request).
  3. Device never got the ack → records stay PENDING.
  4. Next flush re-sends them → server inserts them again.

The network being unreliable in both directions is the whole premise of offline-first, so retries are guaranteed. The fix is idempotency, enforced on both sides:

  • Client: every record carries its device-generated localId (UUID) in the payload — the same one on every retry.
  • Server: a unique constraint on (device_id, local_id) and upsert semantics: "if I've seen this localId, return the previous ack instead of inserting."
-- Server-side: retries become no-ops, not duplicates
INSERT INTO visit_logs (local_id, device_id, ...)
VALUES (?, ?, ...)
ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id);  -- return existing row's id
Enter fullscreen mode Exit fullscreen mode

⚠️ Also on the client: If you have two possible senders — say, a foreground "submit now" path and a background flush service — they can race and double-send the same rows before either ack lands. Either collapse them into one sender, or add a mutex so only one path can flush a given record type at a time. We learned this from a production duplicate-insert bug that only reproduced on slow networks.


Step 7 — Conflicts: pick a policy before you need one

Downstream sync (server → device) eventually meets a record edited in both places. There is no universally correct answer — there is only a policy chosen per table, on purpose:

Strategy Rule Use for
Last-write-wins Higher updatedAt wins Single-owner data (a rep's own notes) — simple, predictable
Server-wins Server copy always replaces local Reference/master data the device merely displays
Client-wins Device copy survives until explicitly synced In-progress work the user is actively editing
Field-level merge Compare per column, merge non-overlapping edits High-value shared records — costs real complexity
Manual resolution Park both versions, ask a human Rare, high-stakes conflicts (approvals, financial data)

In a field-team context, last-write-wins with per-record ownership covers ~90% of cases, because most offline-written records have exactly one author — the device that created them. Design your data model so this stays true and you may never need the expensive strategies.

💡 Clock warning: LWW compares timestamps, and device clocks lie. Record the device's updatedAt but let the server stamp arrival time and sanity-check drift (e.g., reject client timestamps from the future). Never resolve conflicts with unvetted device time alone.


What I'd tell you before you build it

  • Show sync state honestly. A tiny PENDING / SYNCED badge per record, plus a "3 records waiting to sync" strip, converts anxiety into trust. Users forgive a delayed sync; they never forgive silent data loss.
  • Sync your error logs too. When a device misbehaves offline, the evidence is trapped on it. Treat error logs as just another offline-writable table that flushes with everything else — production debugging becomes possible again.
  • Test with the network chaos monkey. Airplane mode mid-flush, killed app mid-request, captive-portal Wi-Fi, 48 hours offline then 400 pending records. Every one of these is a Tuesday for a field user.
  • Never let a stuck record block the queue. Cap retries, quarantine poison records, flag them for support — and keep draining the rest.
  • Design the server API for sync from day one. Batched upserts, per-record acks, idempotency keys. Retrofitting idempotency onto a plain CRUD API is far more painful than building it in.

The payoff

The architecture above is maybe 500 lines of engine code plus a schema convention — small enough to own completely, with no black-box sync SaaS in the middle. What it buys you:

  • Saves that complete in milliseconds, every time, regardless of signal.
  • A field workforce that can work a full offline day and reconcile automatically over lunch Wi-Fi.
  • Zero duplicate submissions — idempotency by construction, not by luck.
  • One write path, one queue, one state machine: an architecture a new team member can hold in their head.

Offline-first isn't a feature you add. It's a decision about where the truth lives — and everything else follows from it.

If you're building something similar — or wrestling with sync bugs in production — I'm happy to compare notes. Drop a comment or reach out.

#ReactNative #OfflineFirst #MobileArchitecture #RealmDB #SyncEngine #MobileDevelopment

Top comments (0)