Mobile Apps with React Native and Expo · State, Data, Offline and Sign-In

Working Offline: Local Storage and a Sync Queue

Keep the app useful without a connection: store data on the device, save the user's changes locally first, queue them, and send them when the network returns, without duplicating anything.

Create a free account to save this page, take notes and track your progress. Sign in

What you'll build

A notes screen that works in airplane mode. New notes appear immediately, are saved on the device, and sync to the server when the connection comes back, even if the app was closed in between.

The idea

Where to store what:

Data Store Why
Small settings, flags, cached JSON AsyncStorage Simple key-value, asynchronous
Structured data you query (lists, search, thousands of rows) expo-sqlite A real SQL database on the device
Tokens, passwords, secrets expo-secure-store Encrypted by the OS keychain/keystore (next lesson)
Large files (images, downloads) expo-file-system Files on disk; store only paths in the database

Offline-first writes work like an outbox:

  1. Apply the change to local state and storage immediately (the UI updates at once: an "optimistic" update).
  2. Add the change to a persisted outbox queue, with a client-generated ID.
  3. When online, send queued changes in order. Remove each only after the server confirms.
  4. The server treats the client ID as an idempotency key, so a change sent twice (app killed mid-request) is applied once.

The code

import AsyncStorage from "@react-native-async-storage/async-storage";
import NetInfo from "@react-native-community/netinfo";
import * as Crypto from "expo-crypto";

type Change = { id: string; kind: "create_note"; payload: { text: string } };
const OUTBOX = "outbox:v1";

export async function addNoteOffline(text: string) {
  const change: Change = { id: Crypto.randomUUID(), kind: "create_note", payload: { text } };
  const outbox: Change[] = JSON.parse((await AsyncStorage.getItem(OUTBOX)) ?? "[]");
  await AsyncStorage.setItem(OUTBOX, JSON.stringify([...outbox, change]));   // survives app restarts
  return change;                                                            // show it in the UI right away
}

export async function flushOutbox(send: (c: Change) => Promise<void>) {
  const outbox: Change[] = JSON.parse((await AsyncStorage.getItem(OUTBOX)) ?? "[]");
  for (const change of outbox) {
    await send(change);                                   // server dedupes by change.id
    const rest: Change[] = JSON.parse((await AsyncStorage.getItem(OUTBOX)) ?? "[]");
    await AsyncStorage.setItem(OUTBOX, JSON.stringify(rest.filter((c) => c.id !== change.id)));
  }
}

// Flush whenever the device comes back online (and on app start).
NetInfo.addEventListener((state) => {
  if (state.isConnected) flushOutbox(sendToServer).catch(() => { /* try again on the next change */ });
});
declare function sendToServer(c: Change): Promise<void>;

Try the logic

This simulation puts a fake network and a fake server together: changes made offline are queued, a send fails halfway, a retry repeats an already-delivered change, and the server's idempotency check keeps the result correct.

Outbox queue + idempotent server = no lost or doubled notesTry it yourself
type Change = { id: string; text: string };

class Server {
  notes: string[] = [];
  private applied = new Set<string>();
  receive(change: Change) {
    if (this.applied.has(change.id)) return "duplicate ignored";   // idempotency by client id
    this.applied.add(change.id);
    this.notes.push(change.text);
    return "saved";
  }
}

class Device {
  outbox: Change[] = [];
  online = false;
  private next = 1;
  constructor(private server: Server) {}

  addNote(text: string) {
    this.outbox.push({ id: `c${this.next++}`, text });   // shown in the UI immediately
  }

  flush(failAfterSending = -1) {
    if (!this.online) return console.log("  offline: kept", this.outbox.length, "in the outbox");
    for (let i = 0; i < this.outbox.length; ) {
      const change = this.outbox[i];
      console.log(`  send ${change.id}: ${this.server.receive(change)}`);
      if (i === failAfterSending) return console.log("  connection lost before the reply; will retry");
      this.outbox.splice(i, 1);                         // remove only after the server confirmed
    }
  }
}

const server = new Server();
const phone = new Device(server);
phone.addNote("Buy milk");
phone.addNote("Call Ben");
phone.flush();
phone.online = true;
phone.flush(0);              // the first send reaches the server, but the reply is lost
phone.addNote("Book flights");
phone.flush();               // retries c1 (duplicate), then sends the rest
console.log("server notes:", server.notes.join(" | "));
console.log("outbox left:", phone.outbox.length);

Output

  offline: kept 2 in the outbox
  send c1: saved
  connection lost before the reply; will retry
  send c1: duplicate ignored
  send c2: saved
  send c3: saved
server notes: Buy milk | Call Ben | Book flights
outbox left: 0

Common mistakes

  • Removing a change before the server confirms. A crash at the wrong moment loses it.
  • No idempotency key. Retries create duplicate notes or double orders.
  • Storing big lists as one AsyncStorage JSON blob. Every change rewrites everything; use SQLite for real data sets.
  • Assuming "connected" means "the internet works". Captive portals and dead Wi-Fi exist; still handle request failures.
  • Ignoring conflicts. If the same record can change on two devices, decide a rule (last write wins, or merge fields) and tell the user when it matters.

Key points

  • AsyncStorage for small values, SQLite for queryable data, SecureStore for secrets, the file system for files.
  • Write locally first, queue changes in a persisted outbox, sync when online.
  • Client-generated IDs make retries idempotent on the server.
  • Remove from the outbox only after confirmation.

Further reading: Expo SQLite, AsyncStorage, NetInfo.

Practice questions

How does an outbox queue make an app work offline?

Answer

User changes are applied locally right away and appended to a persisted queue. When the device is online, the app sends queued changes in order and removes each only after the server confirms, so changes survive restarts and are never lost.

Why does each queued change need a client-generated ID?

Answer

A change can be delivered but its confirmation lost, so the app sends it again. The server uses the client ID as an idempotency key and ignores repeats, so the change is applied exactly once.

Open in the app to mark lessons complete, take notes and track your progress.

Was this page helpful?