Home » Offline State Management in React Native: Queues, Retries, and Conflicts
Latest Article

Offline State Management in React Native: Queues, Retries, and Conflicts

A user updates a delivery address while their phone has no signal. The app displays the new address immediately. Later, connectivity returns, the request reaches the server, and the connection drops before the response arrives.

Should the app retry? Did the server save the change? What happens if another device updated the same address in the meantime?

These are the problems that make offline state management in React Native more demanding than persisting a store.

A reliable implementation must preserve local changes, retry uncertain requests safely, and reconcile competing updates. The architecture needs cooperation between the mobile application and its backend.

What Does Offline State Management Actually Include?

An offline-capable application needs to distinguish three kinds of state:

StateExampleResponsibility
UI stateOpen modal, selected tabManage the current interaction
Local application dataDownloaded tasks, saved draftsKeep useful information available
Synchronization statePending edits, attempts, conflictsTrack what still needs server acknowledgement

Persisting UI state does not create a synchronization protocol. Likewise, caching a successful API response does not preserve future edits automatically.

A practical architecture uses a durable local store for application data and an outbox for changes awaiting synchronization. React components render local data, while a separate worker manages delivery.

Expo’s local-first architecture documentation describes approaches that combine local persistence with state and synchronization layers. The choice of those layers depends on the application’s data model and collaboration requirements.

How Should a React Native Offline Queue Store Changes?

An outbox should record a stable description of each operation rather than a callback or an entire component state snapshot.

A simplified TypeScript model might look like this:

type OutboxOperation = {
  operationId: string;
  accountId: string;
  entityId: string;
  sequence: number;

  kind: "updateTask";
  payload: {
    title?: string;
    completed?: boolean;
  };

  baseVersion: string | null;
  schemaVersion: number;

  status:
    | "pending"
    | "inFlight"
    | "retry"
    | "conflict"
    | "blocked";

  attempts: number;
  nextAttemptAt: number;
  leaseUntil: number | null;
  createdAt: number;
};

Each field serves a purpose:

  • operationId identifies the logical change across retries.
  • accountId prevents one account’s work from being replayed under another account.
  • sequence preserves ordering for related changes.
  • baseVersion records the server version on which an edit depends.
  • schemaVersion supports migrations when the application’s operation format changes.
  • leaseUntil helps recover work interrupted while sending.

Do not persist access tokens inside queue payloads. Resolve current credentials when sending, and verify that they belong to the operation’s account.

Save the Local Edit and Queue Entry Together

A dangerous implementation saves the updated entity first and then writes the queue entry separately. If the application crashes between those writes, the interface can show a change that will never synchronize.

Use a database transaction to commit both records together.

The following is a schematic Expo SQLite example. It assumes the tables already exist and that validation, operation IDs, and per-entity sequencing are handled by the application:

await db.withExclusiveTransactionAsync(async (tx) => {
  await tx.runAsync(
    `UPDATE tasks
     SET title = ?, sync_status = 'pending'
     WHERE account_id = ? AND id = ?`,
    title,
    accountId,
    taskId
  );

  await tx.runAsync(
    `INSERT INTO outbox (
      operation_id, account_id, entity_id,
      payload_json, status, created_at
    ) VALUES (?, ?, ?, ?, 'pending', ?)`,
    operationId,
    accountId,
    taskId,
    JSON.stringify({ title }),
    Date.now()
  );
});

Expo documents withExclusiveTransactionAsync for controlling which queries participate in the transaction. Keep network requests outside the transaction, and check platform support against the SDK version used by the application.

AsyncStorage remains useful for simpler persistence needs, but it is an unencrypted key-value store. A single serialized queue also requires careful coordination of concurrent read-modify-write operations. For a relational outbox with transactional updates, SQLite is a more direct fit.

How Should the Queue Handle Ordering and App Restarts?

A global first-in, first-out queue is easy to understand, but one problematic record can block unrelated work.

A more flexible design preserves ordering within each entity or dependency chain, while allowing limited concurrency across independent entities.

For example:

  • Creating a task must finish before attaching a comment to it.
  • Two edits to the same task should follow a defined sequence.
  • Edits to unrelated tasks may synchronize independently.

Only claim the earliest eligible operation for an entity. If it enters a conflict or blocked state, dependent operations should wait.

Persist the claim before sending. After a crash, an expired lease makes the operation eligible for recovery. Where foreground and background workers can overlap, use transactional claims and a claim token so an outdated worker cannot overwrite a newer worker’s result.

An in-memory isSyncing flag only protects one running JavaScript instance.

Acknowledgement handling also matters. When an earlier edit succeeds, its response must not erase a later local edit. One approach stores the latest server snapshot separately and derives the visible record by applying pending local operations on top.

Why Does Retry Safety Require Backend Support?

A timeout means the client did not receive a response. It does not prove that the server rejected or ignored the operation.

Consequently, a mobile queue can send the same logical operation more than once.

AWS’s guidance on resilient requests emphasizes that retries can repeat side effects and that backoff with jitter helps avoid synchronized retry traffic.

For operations that create side effects, define an idempotency contract with the backend:

POST /tasks
Idempotency-Key: 4a97c219-example-operation-id
Content-Type: application/json

The server must implement the behavior behind that header. It should associate the key with the authenticated account and request fingerprint, then coordinate deduplication with the business write.

A repeated matching request should return the established outcome. Reusing the key with different content should be rejected.

The same logical operation keeps its key across retries. A genuinely new operation gets a new key.

Retention is part of the contract: if a client can replay work after several weeks, a short-lived deduplication record may not protect that replay. The backend needs an appropriate retention policy, stable resource identifiers, or another durable uniqueness mechanism.

Which Failures Should Be Retried?

Retry decisions should follow the API contract rather than treating every failure as temporary.

FailureSuggested handling
Network interruption or timeoutRetry with the same operation identity
Rate limitingRespect Retry-After when provided
Temporary service failureRetry within a bounded policy
Expired authenticationAttempt bounded credential refresh, then pause if needed
Invalid input or denied permissionBlock and surface the reason
Version conflictEnter conflict resolution
Retry budget exhaustedPreserve the operation for recovery or user action

A capped exponential backoff with full jitter can distribute retries:

function retryDelayMs(attempt: number): number {
  const exponent = Math.min(Math.max(attempt, 0), 16);
  const ceiling = Math.min(60_000, 1_000 * 2 ** exponent);

  return Math.floor(Math.random() * ceiling);
}

Here, attempt starts at zero for the first retry. The constants are illustrative, not universal defaults.

Persist nextAttemptAt instead of relying solely on a timer. The worker should read that timestamp whenever it resumes.

If the server supplies Retry-After, account for either its delay-seconds or HTTP-date form and do not schedule earlier than the specified time. These forms are defined by HTTP semantics.

Choose one layer to own mutation retries. A queue worker, API client, and query library retrying independently can multiply requests.

How Should React Native Detect When to Synchronize?

NetInfo exposes both connection status and internet reachability. Its isInternetReachable value can also be null, meaning the answer is not yet known.

Treat connectivity events as scheduling hints. Neither Wi-Fi connectivity nor a successful reachability check guarantees that the application’s API is healthy.

Useful synchronization triggers include:

  • An operation is added while requests appear possible.
  • Connectivity changes.
  • The application returns to the foreground.
  • A scheduled retry becomes eligible.
  • The user requests synchronization.

A policy may allow a bounded request attempt when reachability is unknown, rather than interpreting null as definitely offline.

Background execution is opportunistic. Expo documents that the operating system decides when background tasks run, and execution may occur later than the requested interval. Queue correctness must therefore survive suspension and restart.

How Should Conflicting Offline Edits Be Resolved?

Suppose two devices download task version 12.

Device A updates the task, producing version 13. Device B later submits an offline edit based on version 12.

Without concurrency checks, device B may silently overwrite device A’s work.

For HTTP APIs, a server can expose a strong ETag and require If-Match on updates. If the precondition fails, HTTP defines 412 Precondition Failed. An application-specific version protocol may instead use a documented conflict response.

Detection is only the first step. Resolution depends on the data.

Choose a Policy for Each Kind of Change

Server wins can suit authoritative reference data. It is less suitable when discarding local user input would be unacceptable.

Last-write-wins can suit low-risk preferences when overwrite behavior is intentional. Device clocks should not be assumed trustworthy for ordering.

Three-way merging compares the original base, the local edit, and the current server record. Disjoint field changes may be mergeable, but cross-field business rules still need validation.

User-assisted resolution is appropriate when both changes are meaningful and an automatic choice would lose intent.

A conflict response should preserve the local proposal and retrieve the current server state. A resolved edit becomes a new operation with a fresh identity and the appropriate current version.

Retries alone do not resolve conflicts.

Where Does TanStack Query Fit?

TanStack Query is useful for server-state caching and mutation lifecycle management. Its documentation describes paused offline mutations, persistence, and resuming them after restoration. Persisted mutations need a default mutation function because functions themselves are not serialized.

For React Native, its integration guidance covers connecting network and application lifecycle events to the relevant managers.

For modest offline requirements, persisted mutations may be sufficient. Applications needing atomic domain writes, dependency tracking, long-lived queues, and explicit conflict review may benefit from a dedicated outbox.

In that architecture, the outbox owns mutation delivery. TanStack Query can still manage remote reads and refresh affected data after acknowledgement. Avoid allowing both systems to replay the same mutation independently.

What Should an Offline Sync Test Plan Cover?

Airplane-mode testing is necessary, but insufficient.

ScenarioExpected result
App closes after a local saveEdit and queue entry survive
Server commits but response is lostReplay does not duplicate the effect
App closes during deliveryInterrupted work becomes recoverable
A second device changes the recordConflict is detected
Authentication expiresWork remains queued without an endless retry loop
User switches accountsPrevious account’s operations cannot replay under the new identity
An older response arrives after a newer local editNewer local work remains visible
App upgrades with pending operationsQueue schema migration preserves or explicitly blocks them

Operational metrics should include the oldest pending operation, queue depth, conflict rate, retry volume, and permanent failures. Queue age can reveal stuck synchronization that a successful-request metric misses.

The interface should distinguish “saved on this device,” “syncing,” “synced,” and “needs attention.” Users should not have to infer whether their work reached the server.

Reliable offline behavior comes from preserving intent through uncertainty: local durability, controlled delivery, backend deduplication, and explicit conflict decisions.