- Offline storage is five categories, not one library: key-value, secure, structured, file system, and server-state cache.
- MMKV is the new default over AsyncStorage — roughly 30× faster with synchronous reads.
- The offline-first pattern has four parts: local-first reads, optimistic writes, a durable mutation queue, and a sync engine.
- Conflict resolution: last-write-wins is fine for most consumer apps. CRDTs are overkill unless you're building a collaborative editor.
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) orMMKV(default). MMKV is ~30× faster and supports synchronous 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 with
persistQueryClient, backed by MMKV.
Comparison table
| Library | Type | Perf | Encryption | Best for |
|---|---|---|---|---|
| AsyncStorage | KV | 1× | Community fork | Legacy / compat |
| MMKV | KV | ~30× | 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. The UI always reads from the local store. The network populates the store; the store renders the UI.
- Optimistic writes. The UI updates immediately, before the server acknowledges.
- Mutation queue. A durable list (SQLite or MMKV) of pending server-side changes, each with a client ID.
- Sync engine. A background worker drains the queue with exponential backoff and 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
- Last-write-wins. Timestamps, newest wins. Fine for most consumer apps.
- Server-authoritative. Reject stale writes with a version mismatch.
- CRDTs. Yjs or Automerge. Overkill unless you're building a collaborative editor.
Decision cheat sheet
- Preferences or JWT? → MMKV +
expo-secure-store - Instant-open cached lists? → TanStack Query with
persistQueryClienton MMKV - Thousands of offline records? →
op-sqlite, or WatermelonDB if you want reactive - Cross-device sync? → WatermelonDB or Realm
- Media? →
expo-file-systemwith metadata in SQLite
Pitfalls to skip
- Blocking the JS thread with async
AsyncStoragecalls at startup - Storing 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 entirely.
Pick each layer deliberately and this becomes a solved problem rather than 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.
Anyone running MMKV + TanStack Query in production? Curious how the persistence layer has held up for you at scale.