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.
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:
- Apply the change to local state and storage immediately (the UI updates at once: an "optimistic" update).
- Add the change to a persisted outbox queue, with a client-generated ID.
- When online, send queued changes in order. Remove each only after the server confirms.
- 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.
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.