Most React Native apps treat the network as an always-on dependency, and it shows the moment a request stalls. Offline storage isn't one library — it's a stack: key-value stores for preferences, secure enclaves for tokens, structured DBs for domain data, and query caches for server state. This post walks through the whole stack and the offline-first pattern that ties it together.
The five categories of RN offline storage
- Key-value — AsyncStorage (compat) or MMKV (default). MMKV is ~30x faster and supports sync reads.
-
Secure —
expo-secure-storeorreact-native-keychain. Backed by iOS Keychain / Android Keystore. -
Structured —
expo-sqlite,op-sqlite(JSI, synchronous), WatermelonDB (reactive + sync), Realm (Atlas Device Sync). -
File system —
expo-file-systemfor media. Never base64 blobs into KV storage. -
Server-state cache — TanStack Query +
persistQueryClient, backed by MMKV.
Comparison table
| Library | Type | Perf | Encryption | Best for |
|---|---|---|---|---|
| AsyncStorage | KV | 1x | Community fork | Legacy/compat |
| MMKV | KV | ~30x | Built-in | New default |
| expo-secure-store | Secure KV | — | OS keychain | Tokens |
| op-sqlite | SQL | JSI | SQLCipher | Structured data |
| WatermelonDB | Reactive DB | Lazy | SQLCipher | Large offline lists + sync |
| Realm | Object DB | Fast | Yes | Atlas Device Sync |
The offline-first pattern
Four moving parts:
- Local-first reads — UI always reads from local store. Network populates store; store renders UI.
- Optimistic writes — UI updates immediately, before the server acks.
- Mutation queue — Durable list (SQLite or MMKV) of pending server-side changes with client IDs.
- Sync engine — Background worker drains the queue with exponential backoff, reconciles conflicts.
async function markMessageAsRead(id: string) {
db.write(() => {
db.messages.update(id, { readAt: Date.now(), pendingSync: true });
});
await mutationQueue.enqueue({
kind: 'message.markRead',
payload: { id, readAt: Date.now() },
clientId: uuid(),
});
syncEngine.wake();
}
Conflict resolution
- LWW — timestamps, newest wins. Fine for most consumer apps.
- Server-authoritative — reject stale writes with a version mismatch.
- CRDTs — Yjs, Automerge. Overkill unless you're building a collaborative editor.
Decision cheat sheet
- Prefs / JWT? → MMKV + expo-secure-store
- Instant-open cached lists? → TanStack Query +
persistQueryClienton MMKV - Thousands of offline records? → op-sqlite (or WatermelonDB for reactive)
- Cross-device sync? → WatermelonDB or Realm
- Media? → expo-file-system + metadata in SQLite
Pitfalls to skip
- Blocking JS thread with async AsyncStorage at startup
- Blobs in KV stores
- No cache-wipe on logout
- Infinite retry loops in the mutation queue
- Persisting Redux state without a schema version
Wrap-up
Offline-first isn't a defensive posture — it's a performance strategy. Reading from local storage beats a round-trip every time, and a good sync engine hides the network from your users. Pick each layer deliberately and this becomes a solved problem, not a recurring one.
I built RapidNative, an AI mobile app builder that generates production-ready React Native code you can extend with any of these libraries. Would love feedback from anyone using MMKV + TanStack Query in production.
Top comments (0)