skip to content

In an offline-capable React Native app, why must pending writes go into a persisted outbox rather than memory, and when is it replayed?

level: juniorimportance: must knowfreq 40%

answer

  1. process death loses memory
  2. write to disk before showing success
  3. one record per write, with an id
  4. NetInfo listener, launch, return to active
  5. remove only after the server confirms

basics

~20 s

Pending writes held in memory vanish when the OS kills the app or it crashes. A persisted outbox keeps each write on disk until the server confirms it, replaying on reconnect, at launch and on return to the foreground.

solid answer

~40 s

A phone app's process can end at any time: the OS kills it in the background, the user swipes it away, it crashes or updates, and no event warns you. Writes held only in component state or a store are lost with it, so each pending write is saved as a record, with its own id, payload and timestamp, in durable storage such as MMKV, AsyncStorage or SQLite before the UI shows success. A replay loop sends records oldest first and deletes each only after the server confirms it. It runs when `NetInfo.addEventListener` reports `isConnected`, at launch, and when `AppState` returns to `active`, with an in-flight flag so overlapping triggers do not send a record twice, and it stops at the first network failure to keep the order.

code

typescript · 35 lines
typescript
import NetInfo from '@react-native-community/netinfo';
import { AppState } from 'react-native';
import { outbox } from './outboxStore'; // the app's durable queue
import { sendScan } from './api';

let replaying = false;

export async function replayOutbox() {
  if (replaying) return;
  replaying = true;
  try {
    for (const item of await outbox.pendingOldestFirst()) {
      const outcome = await sendScan(item); // throws on network errors
      if (outcome === 'rejected') await outbox.moveToFailed(item.id);
      else await outbox.remove(item.id);
    }
  } catch {
    // Stop at the first failure; the next trigger resumes from this item.
  } finally {
    replaying = false;
  }
}

export function startOutboxReplay() {
  const unsubscribeNetInfo = NetInfo.addEventListener(state => {
    if (state.isConnected) void replayOutbox();
  });
  const appState = AppState.addEventListener('change', next => {
    if (next === 'active') void replayOutbox();
  });
  return () => {
    unsubscribeNetInfo();
    appState.remove();
  };
}

go deeper

for a junior

Remember why memory is not enough: the process can be killed at any time. Save each pending write to storage first, and replay it when the device is back online.

for a middle

Explain the record shape, the replay triggers (NetInfo, launch, return to active), and why deletion waits for the server's confirmation.

for a senior

Show the correctness rules: single-flight replay, stop at the first network failure, move rejected records aside, and surface a pending count so users trust the queue.

for a principal

Decide which features truly need offline writes, because each one adds an outbox, a replay path and a user-facing failure state the team must support.

## The scenario A warehouse picker walks the aisles scanning items into orders. Half the aisles have no signal. Every scan is a write the server must eventually receive, and the picker cannot wait for a spinner at each shelf. The app has to accept the scan at once, show it as done, and deliver it later. The standard shape for that in a React Native app is an **outbox**: a queue of pending writes that lives on the device until the server confirms each one. ## Why the outbox must be persisted Holding pending writes in memory (component state, a store, a module-level array) works until the process ends, and on a phone that happens without warning: - The OS can **kill a backgrounded app** to reclaim memory, and no JavaScript event announces it. - The user can **swipe the app away** or the phone can run out of battery at the end of a shift. - A **crash** anywhere in the app takes the in-memory queue with it. - An **app update** installed overnight restarts the process. Every one of those loses scans the picker believes were recorded. So each pending write goes to **durable storage** on the device (a key-value store such as MMKV or AsyncStorage, or a table in SQLite) at the moment it is made, before the UI reports success. ## What an outbox record holds | Field | Why it is there | |---|---| | `id` | A unique id generated when the write is queued; it identifies the write across retries | | `type` and `payload` | What to send, for example a scan of SKU and quantity for an order | | `createdAt` | Keeps replay in the order the picker worked | | `attempts` and `lastError` | Lets the app back off and show what is stuck | The UI reads the local result straight away (the scan appears as picked, marked pending), and the record stays in the outbox until the server has accepted it. ## When the outbox is replayed Replay is a loop that sends records oldest first and removes each one only after the server confirms it. It is started from several triggers, because none of them is enough alone: 1. **Connectivity returns.** `NetInfo.addEventListener` from `@react-native-community/netinfo` calls its listener with the current state as soon as you subscribe and again on every change. Replay when `isConnected` is true; `isInternetReachable` can still be `false` on a network with no route out, or `null` while it is unknown. 2. **The app starts.** Anything left over from a killed session is replayed at launch. 3. **The app comes back to the foreground.** An `AppState` change to `active` is a cheap moment to try again. 4. **The user asks.** A visible "sync now" and a pending count help pickers trust the app. ## Rules that keep replay correct - **One replay at a time.** Several triggers can fire together; a simple in-flight flag stops the same record being sent twice at once. - **Stop at the first network failure.** The next trigger resumes from the same record, so order is kept. - **Separate rejection from failure.** If the server rejects a record as invalid, move it aside and tell the user; left at the head of the queue, it would block every later scan. - **Remove only after confirmation.** Deleting a record before the response arrives loses it if the process dies mid-request. ## A worked example from the warehouse The picker scans three items in aisle 12, where there is no signal. Each scan writes a record to the outbox and bumps the picked count on screen, marked pending. The phone locks in a pocket, and the OS kills the backgrounded app to free memory. Twenty minutes later the picker opens the app at the packing station, on Wi-Fi. At launch the replay loop reads three records, oldest first. The first two succeed and are removed. The third is rejected because the order was cancelled in the meantime, so it is moved to a failed list and the picker sees a message on that order. Nothing was lost, nothing was sent out of order, and the one real problem is visible to the person who can fix it. ## What this leaf leaves to others Which storage library to use and how it works, how to detect connectivity precisely, and how to resolve conflicting edits on the server are separate subjects. The outbox itself is small: a durable list, a replay loop, and the discipline to delete a record only when the server has said yes.

  • Why should a React Native outbox replay stop at the first network failure instead of skipping to the next record?
    A network failure usually means every later request would fail too, and skipping ahead breaks the order the user worked in: a later scan could reach the server before an earlier one it depends on. Stopping keeps the head record in place, and the next trigger, a NetInfo change, launch or return to the foreground, resumes from it.
  • What should a React Native outbox do with a record the server rejects as invalid?
    Take it out of the replay path, for example by moving it to a failed list, and show it to the user with the server's reason. Left at the head of the queue, it would be retried forever and block every later write. Rejection is a different outcome from a network failure, which should leave the record where it is.

An outbox is the paper tray on a courier's desk: every parcel is logged in the tray the moment it is handed over, and it only leaves the tray when the recipient signs for it, so a courier going home early never loses one.

saying these in an interview costs you the question

  • Keeping pending writes in a global store is safe because the app rarely restarts.
  • Deleting an outbox record just before sending it is fine because the request will succeed.
  • NetInfo's isConnected means the server is definitely reachable.
  • Replaying only when the device reconnects is enough; launch needs no replay.
  • A rejected record should stay at the head of the queue until it succeeds.