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.
| Action | Offline policy | User-visible result |
|---|---|---|
| Open previously loaded records | Read local database | Immediate content with last-sync context |
| Edit a draft or field note | Local transaction plus outbox | Saved locally; pending badge until accepted |
| Delete a shared record | Tombstone plus outbox, if reversible | Hidden locally; conflict can restore it |
| Payment or scarce booking | Usually online-only | Clear connection requirement; no false success |
Use a local source of truth and a resumable sync engine
- Render from local queries. Screens do not wait on API calls or combine remote and local objects ad hoc.
- Commit intent atomically. A user edit updates the local row and inserts an outbox operation in one database transaction.
- Push repeatably. The sync engine sends stable operation IDs; the server deduplicates them at the same transaction boundary as the domain change.
- Pull incrementally. The client requests server changes after an opaque cursor, including deletion tombstones.
- Apply atomically. The client stores returned changes and advances the cursor in one local transaction.
- 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 eligible5xxresponses 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.
| Conflict | Useful strategy | Trade-off |
|---|---|---|
| Profile fields edited on two devices | Field-level merge with server versions | More metadata and merge logic |
| Append-only note or message | Unique item IDs; keep both | Ordering still needs a server sequence |
| Counter or inventory | Send domain operations, not absolute values | Server must validate each operation |
| Long-form document | Manual merge, version history, or a purpose-built collaborative algorithm | More UI and storage complexity |
| Delete versus offline edit | Explicit policy: deletion wins, restore as copy, or human review | No 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.
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