Core principle
Local storage is the source of truth. Every user action writes locally first and succeeds immediately. Sync to the server happens in the background when online. The UI never shows a spinner for writes.
Local storage options for Expo in 2026
| Library | Best for | Notes |
|---|---|---|
| MMKV | Settings, session, small objects | Fastest, synchronous reads, small API |
| AsyncStorage | Simple key-value | Works, slower, legacy fallback |
| SQLite (expo-sqlite, op-sqlite) | Relational data | Use with Drizzle ORM for ergonomics |
| WatermelonDB | Large relational datasets | Built-in sync primitives |
| PowerSync | Real-time sync with Postgres | Commercial, excellent DX |
| Realm | Objects + sync | Mongo-owned, robust |
The recommended 2026 stack for most apps
- MMKV for small/fast data — current user, settings, session tokens.
- SQLite via Drizzle for relational data — tasks, posts, orders.
- Supabase as the remote backend. Use
@tanstack/react-queryor a custom sync layer to mirror tables locally. - NetInfo from
@react-native-community/netinfoto detect connectivity.
This covers 90% of consumer and prosumer apps. For collaborative or real-time products (shared docs, live cursors), consider PowerSync or a CRDT-backed stack.
Sync strategies: picking the right one
- Optimistic UI + background sync: writes land locally instantly; a queued job syncs to the server. Simple, works for most apps.
- Last-write-wins (LWW): server timestamp decides conflicts. Good default; loses data only in edge cases of simultaneous multi-device edits.
- CRDT: conflict-free replicated data types. For collaborative editing or shared documents. Heavy to implement; libraries like Yjs and Automerge help.
- Queue-based sync: every local mutation goes to a queue table. When online, the queue drains in order. Retry on failure. Simple, robust, easy to debug.
A minimal queue-based sync implementation
// Local write:
async function createTask(task) {
const id = uuid();
// 1. Write to local SQLite immediately
await db.insert(tasks).values({ id, ...task,
synced: false });
// 2. Enqueue for sync
await db.insert(sync_queue).values({
action: 'insert',
table: 'tasks',
payload: { id, ...task },
created_at: Date.now(),
});
// 3. UI updates instantly
return id;
}
// Sync drain:
import NetInfo from
'@react-native-community/netinfo';
NetInfo.addEventListener(async (state) => {
if (state.isConnected) await drainQueue();
});
async function drainQueue() {
const items = await db.select().from(sync_queue)
.orderBy(sync_queue.created_at);
for (const item of items) {
try {
await syncToSupabase(item);
await db.delete(sync_queue)
.where(eq(sync_queue.id, item.id));
await db.update(tasks)
.set({ synced: true })
.where(eq(tasks.id, item.payload.id));
} catch (e) {
// leave in queue, retry next time online
break;
}
}
}Conflict resolution in practice
Two devices edit the same task offline. On sync, whose wins? The simplest pattern:
- Store
updated_aton every row (both local and server). - On sync, compare timestamps. Newer wins. Server is authoritative on ties.
- Keep a
sync_conflictstable for cases where the user should see both versions (rare, worth showing). - For delete conflicts, use soft deletes (
deleted_attimestamp) and sync that. Hard deletes are ambiguous offline.
Detecting and responding to connectivity
- Use
NetInfo.addEventListenerto react to connectivity changes. - Show a subtle “Offline” indicator in the UI — not a blocking modal.
- Distinguish “no internet” from “internet but server unreachable” — both land in queue, but showing different copy builds trust.
- Always let writes succeed locally. Never block UI on connectivity.
Getting AI builders to generate offline-first code
AI app builders default to network-required patterns unless asked. Anchor words that steer toward offline-first:
- “Offline-first,” “optimistic UI,” “local is source of truth”
- “MMKV” and “SQLite via Drizzle”
- “Sync queue that drains when online”
- “No spinners on writes”
- “Last-write-wins conflict resolution”
Apps generated by ShipNative with those anchors produce the sync queue + local storage scaffolding for you. See the broader prompt playbook in How to Write Prompts That Produce Better React Native Code.
Testing offline flows
- Toggle airplane mode on a physical device (not a simulator).
- Create, edit, delete records while offline. Verify UI succeeds instantly.
- Reconnect and verify the queue drains.
- Edit the same record on two devices while both offline, then reconnect both. Verify conflict resolution behaves as expected.
- Kill the app mid-sync to confirm queue items persist and retry on next launch.
Common pitfalls
- Blocking writes on network. Writes should always land locally first.
- No retry logic. Transient network failures must not lose data.
- Hard deletes without soft-delete markers — creates sync ambiguity.
- Spinners on writes. Offline-first means instant UI.
- Forgetting offline testing. Simulators lie about network behavior.