Mobile data architecture

React Native Offline-First Sync: Production Architecture

An offline-first app is not a screen cache with a retry button. It is a distributed system in which the device owns durable local state, the server settles shared truth, and synchronization is allowed to stop and resume at any instruction.

Offline-first mobile sync architecture connecting a local data store and durable queue to a cloud service

Define which product actions must work offline

“Works offline” is too vague to implement or test. List each user action and classify it as local read, queued write, or online-only transaction. Reading a downloaded field report can be local. Editing its notes can be queued. Confirming a payment, reserving scarce inventory, or approving a permission change may need a live server decision.

Android's official offline-first data-layer guidance defines the local data source as the application's canonical source for reads and distinguishes online-only, queued, and lazy writes. That model applies well to React Native even though storage and background-work libraries differ by platform.

Product rule: never show a queued action as globally confirmed. Use explicit states such as saved on this device, waiting to sync, synced, needs attention, and rejected.
ActionOffline policyUser-visible result
Open previously loaded recordsRead local databaseImmediate content with last-sync context
Edit a draft or field noteLocal transaction plus outboxSaved locally; pending badge until accepted
Delete a shared recordTombstone plus outbox, if reversibleHidden locally; conflict can restore it
Payment or scarce bookingUsually online-onlyClear connection requirement; no false success

Use a local source of truth and a resumable sync engine

  1. Render from local queries. Screens do not wait on API calls or combine remote and local objects ad hoc.
  2. Commit intent atomically. A user edit updates the local row and inserts an outbox operation in one database transaction.
  3. Push repeatably. The sync engine sends stable operation IDs; the server deduplicates them at the same transaction boundary as the domain change.
  4. Pull incrementally. The client requests server changes after an opaque cursor, including deletion tombstones.
  5. Apply atomically. The client stores returned changes and advances the cursor in one local transaction.
  6. Surface exceptions. Validation failures, authorization changes, and semantic conflicts become visible states, not infinite retries.

This architecture accepts that a process can stop after any step. If an upload succeeded but the response was lost, the same operation is safe to repeat. If downloaded changes were written but the cursor was not, the page is safe to replay. If neither transaction committed, no partial state is presented as complete.

Teams implementing mobile products can combine Endurance Softwares' repository-supported React Native application development with Node.js mobile API engineering when the client and synchronization contract need to evolve together.

Model local entities, pending operations, and sync metadata

Use a durable database for relational or queryable product data. A key-value store is useful for small preferences, but it makes atomic entity-plus-outbox writes, indexed queries, migrations, and referential cleanup harder. Expo's current SQLite documentation provides persisted databases and parameterized async APIs; it also warns that bulk execAsync() strings do not escape parameters. Bind all untrusted values.

CREATE TABLE tasks (
  id TEXT PRIMARY KEY NOT NULL,
  title TEXT NOT NULL,
  completed INTEGER NOT NULL DEFAULT 0,
  server_version INTEGER,
  sync_state TEXT NOT NULL,
  deleted_at TEXT
);

CREATE TABLE sync_outbox (
  operation_id TEXT PRIMARY KEY NOT NULL,
  entity_type TEXT NOT NULL,
  entity_id TEXT NOT NULL,
  operation_type TEXT NOT NULL,
  base_version INTEGER,
  payload_json TEXT NOT NULL,
  attempts INTEGER NOT NULL DEFAULT 0,
  next_attempt_at TEXT NOT NULL,
  created_at TEXT NOT NULL
);

CREATE INDEX sync_outbox_due
  ON sync_outbox(next_attempt_at, created_at);

Use client-generated, collision-resistant IDs so a record can be referenced before the server sees it. Keep the operation ID separate from the entity ID: one entity may have several edits, and one logical operation must remain identifiable across retries. Store schema and protocol versions when payloads can outlive an app release.

Write the entity and outbox row in one transaction

type TaskDraft = { id: string; title: string };

async function createTaskOffline(db: LocalDatabase, task: TaskDraft) {
  const operationId = crypto.randomUUID();
  const createdAt = new Date().toISOString();

  await db.transaction(async (tx) => {
    await tx.run(
      "INSERT INTO tasks (id, title, sync_state) VALUES (?, ?, ?)",
      [task.id, task.title, "pending"],
    );
    await tx.run(
      "INSERT INTO sync_outbox " +
      "(operation_id, entity_type, entity_id, operation_type, " +
      "payload_json, next_attempt_at, created_at) " +
      "VALUES (?, ?, ?, ?, ?, ?, ?)",
      [operationId, "task", task.id, "create",
       JSON.stringify(task), createdAt, createdAt],
    );
  });
}

LocalDatabase is intentionally an application-owned interface; adapt it to the database library your React Native stack supports. Keep transactions short and serialize writes where the driver requires it. SQLite permits multiple readers but only one simultaneous writer, as its transaction documentation explains.

Design push and pull as idempotent protocols

Push stable operations, not the current screen state

Claim a small due batch, send operations in a deterministic order per entity, and include authentication, an operation ID, entity ID, operation type, base server version, and versioned payload. The server should record the authenticated user or tenant plus operation ID under a unique constraint. It applies the domain mutation and saves the operation result in one transaction, then returns the same result for a duplicate request.

POST /v1/sync/operations
Idempotency-Key: 01K...stable-operation-id
Content-Type: application/json

{
  "entityType": "task",
  "entityId": "01K...client-created-id",
  "operation": "update",
  "baseVersion": 7,
  "patch": { "title": "Inspect pump room" },
  "protocolVersion": 1
}

An idempotency key protects a repeated operation; it does not decide whether an edit based on version 7 may overwrite version 9. Enforce both deduplication and a version precondition. HTTP's standardized If-Match precondition is explicitly intended to prevent lost updates; a domain-specific baseVersion can provide the same decision point in a batch sync protocol. Our Node.js idempotency-key guide covers the durable server boundary in detail.

Pull from an opaque, server-issued cursor

Return a bounded, consistently ordered change feed. The cursor should represent a server position, not a device timestamp: wall clocks drift, records can share timestamps, and a late transaction can sort behind an earlier read. Include tombstones so an offline device can learn that a record was deleted.

GET /v1/sync/changes?cursor=opaque-position&limit=200

{
  "changes": [
    { "type": "task", "id": "01K...", "version": 8,
      "deleted": false, "data": { "title": "Inspect pump room" } }
  ],
  "nextCursor": "opaque-next-position",
  "hasMore": false
}

Within one local transaction, upsert only versions newer than the stored server version, apply tombstones, and save nextCursor. Pull until hasMore is false, but cap foreground work so synchronization does not monopolize the UI thread, radio, or battery. After pushing, pull again because a successful write may have produced normalized fields or related server changes.

Retry only recoverable failures

  • Retry network loss, timeouts, 429, and eligible 5xx responses with exponential backoff, jitter, and a maximum delay.
  • Pause for authentication renewal on 401; do not multiply refresh requests.
  • Treat 403, invalid payloads, unsupported protocol versions, and business-rule rejections as reviewable permanent failures.
  • On an ambiguous timeout, resend the same operation ID rather than creating a replacement operation.
  • Bound batches and attempts, but retain user-authored data until the user resolves or deliberately discards it.

Resolve conflicts according to domain meaning

Last-write-wins is simple but unsafe when timestamps are client-controlled or when two fields carry independent intent. Choose a strategy per entity and operation, document it in the product language, and let the server make the authoritative decision.

ConflictUseful strategyTrade-off
Profile fields edited on two devicesField-level merge with server versionsMore metadata and merge logic
Append-only note or messageUnique item IDs; keep bothOrdering still needs a server sequence
Counter or inventorySend domain operations, not absolute valuesServer must validate each operation
Long-form documentManual merge, version history, or a purpose-built collaborative algorithmMore UI and storage complexity
Delete versus offline editExplicit policy: deletion wins, restore as copy, or human reviewNo universal correct answer

When the server rejects a stale baseVersion, return the current safe representation and a machine-readable conflict code. Preserve the local draft, mark it conflicted, and offer a domain-appropriate resolution. Never discard it merely because the device reconnected.

Tombstones need a retention rule. If a device can remain offline longer than tombstones are kept, the protocol must force a full resnapshot or use a server generation marker. Otherwise an old device can resurrect deleted data or keep records that no longer exist.

Treat connectivity and background events as sync triggers, not guarantees

Start a bounded sync on app launch, sign-in, foreground transition, user refresh, successful local write, network restoration, push notification, and eligible platform background work. Coalesce these triggers into one single-flight sync per account so several signals do not drain the same outbox concurrently.

React Native's official AppState API reports foreground and background transitions, but a transition does not promise enough execution time to finish a batch. Android recommends WorkManager for persistent scheduled work that survives app exits and reboots while respecting system constraints. Apple's development guidance notes that a scheduled background task can be delayed for many hours.

Design consequence: background sync improves freshness; it cannot be the only path to correctness. Every batch must tolerate cancellation, and the next foreground run must be able to resume from durable state.

A connectivity library can tell you that a network interface appears available; it cannot prove that DNS, captive portals, authentication, or your API works. Use connectivity as a scheduling hint, then attempt a bounded request and classify the actual result.

Protect offline data across accounts, devices, and logs

  • Authorize every pushed operation and pulled record on the server; never trust a tenant or owner ID from the payload.
  • Keep access and refresh tokens in platform-protected credential storage, not beside ordinary application rows.
  • Partition local data, cursors, and outbox rows by authenticated account. On logout, either complete an explicit safe handoff or remove the account's local material.
  • Minimize offline fields. Do not download secrets or regulated data merely because a screen might need them later.
  • Use transport security and apply database encryption when the threat model requires it; encryption does not replace authorization or device-compromise planning.
  • Redact payloads from logs, crash reports, analytics, and sync diagnostics. Stable operation IDs are usually enough for correlation.
  • Validate local and remote payloads. A corrupt database row, stale app version, or compromised device must not bypass server business rules.

Remote wipe is opportunistic: an offline or powered-down device may never receive the command. Set honest product expectations, use operating-system protection, shorten the local retention of sensitive data, and revoke server credentials promptly.

Observe whether devices converge, not only whether requests succeed

Measure pending-operation count and age, sync start reasons, batch size, push and pull duration, retry classes, conflict count, permanent failures, cursor lag, local database errors, and protocol-version distribution. Keep IDs and user data out of metric labels.

Provide a privacy-safe support view showing last successful push, last successful pull, pending and conflicted counts, app and protocol versions, and a copyable correlation ID. A healthy API dashboard can hide a device that has not advanced its cursor for days.

Version local schemas and the network protocol independently. Deploy the server so it accepts supported older clients before releasing a new mobile binary; app-store adoption is gradual. Rehearse local migrations with realistic database sizes and interrupted upgrades. For a broader release baseline, pair this guide with our mobile observability and performance checklist.

Test synchronization by interrupting every boundary

  • Create, edit, and delete in airplane mode; restart the process and device before reconnecting.
  • Kill the app after the local entity write and verify the outbox is present because both committed atomically.
  • Let the server commit, drop the response, then resend the same operation ID and verify one domain effect.
  • Deliver duplicate and overlapping change pages; confirm version checks and cursor transactions converge.
  • Edit the same record on two devices and exercise every documented conflict policy.
  • Test clock skew, expired credentials, revoked membership, validation failures, 429, timeouts, and partial service outages.
  • Expire or cancel background work mid-batch and confirm foreground sync resumes without special repair.
  • Keep a device offline beyond tombstone retention and verify the server requests a safe resnapshot.
  • Run local database migrations with pending operations from every supported app version.
  • Check accessibility and copy for pending, failed, conflicted, stale, and offline states.

Property-based or model-based tests are valuable for the sync reducer: for any ordering of duplicate pushes and replayed pulls, applying the same accepted server history should converge to the same visible state. End-to-end device tests should then cover platform scheduling, storage, and lifecycle behavior.

React Native offline-first production checklist

✓ Offline capabilities are defined per user action

✓ Screens read one durable local source of truth

✓ Entity edits and outbox operations commit atomically

✓ Stable operation IDs make ambiguous retries safe

✓ Server versions prevent silent lost updates

✓ Pull cursors and changes commit together

✓ Tombstones and full-resnapshot rules are explicit

✓ Conflicts preserve user work and surface clearly

✓ Background tasks are optional freshness triggers

✓ Security, migrations, telemetry, and failure tests ship together

Build for interruption from the first data model

The best offline experience is not optimistic UI alone. It is a durable protocol that makes every local intent traceable, every retry safe, and every unresolved conflict honest.

Discuss your mobile synchronization architecture
Shares

Request Free Consultation

Get Quote
Let's build something powerful

Have a project idea? Let’s turn it into a scalable product.

Book Free Consultation

© 2026 Endurance Softwares. All rights reserved.