Small key-value → react-native-mmkv (fast, synchronous). Relational/large → op-sqlite (perf) or expo-sqlite (Expo first-party). Secrets/tokens → the OS Keychain/Keystore, never AsyncStorage: expo-secure-store on Expo, react-native-keychain on bare RN. What changes the pick is data shape and secrecy, not platform.
structured/queryable data on an Expo app, no measured SQLite bottleneck → expo-sqlite
structured/queryable data where query throughput or memory matters → op-sqlite (18.x; not if you depend on crsqlite)
secrets on an Expo app → expo-secure-store (first-party, released with the SDK)
secrets on bare RN, or access-control/biometry options secure-store does not expose → react-native-keychain (pin 10.0.0; no npm release since 2025-03-23)
app already on WatermelonDB → WatermelonDB (keep; @nozbe/watermelondb 0.28.0, 2025-04-07, is the latest npm release — new relational work goes to op-sqlite or expo-sqlite)
P2P / local-first app → Hypercore/Autobase/Hyperbee log as primary persistence (KV like AsyncStorage only for local prefs; RB-E-P2P owns it)
Options & tradeoffs
the field considered — and why each one isn’t the default here
option
tradeoff
evidence
@react-native-async-storage/async-storage
Simple async key-value for prefs with no perf concern. v3 is instance-based (createAsyncStorage, getMany/setMany/removeMany) with the v2 default export kept for back-compat. 3.1.1 (2026-05-29, verified vs npm); see note: ASYNCSTORAGE V3.
react-native-mmkv (v4)
Fast synchronous key-value, the common AsyncStorage replacement; v4 is a full Nitro Modules rewrite (restores Old-Arch compat). Writes block the JS thread and a worklet does not offload them (see reading). 4.3.2 (2026-06-22, verified vs npm).
1.7M/wk · ships in 6/14
op-sqlite / expo-sqlite
Relational SQLite for structured/queryable data; op-sqlite is the high-perf JSI option, expo-sqlite the first-party Expo one. op-sqlite 18.2.5 (2026-09-20), expo-sqlite 57.0.3 (2026-09-11), both verified vs npm; see note: OP-SQLITE 18.
880k/wk
expo-secure-store
Secrets/tokens on the Keychain/Keystore; first-party, ships and releases with the Expo SDK — the default secrets store on any Expo app. 57.0.4 (2026-09-11, verified vs npm).
7.8M/wk · ships in 1/14
react-native-keychain (10.0.0)
The bare-RN secrets store, and the pick when you need finer access-control/biometry configuration than secure-store exposes. QUIET: 10.0.0 (2025-03-23) is still the latest npm release (2026-09-24, verified vs npm); see note: KEYCHAIN.
THE PICTURE: "where do I store this" is three questions: small key-value (prefs, flags), relational or large structured data (anything you query), and secrets (tokens, credentials). Each optimizes a different thing (read latency, queryability, OS-level protection), so each has a different home. The classic failure is one tool answering all three; AsyncStorage is fine for prefs, wrong as a database and never a token vault.
OP-SQLITE 18 (2026-09-24, verified vs the GitHub releases): 18.0.0 (2026-08-14) REMOVES crsqlite → an app using op-sqlite as the local half of a CRDT-synced store loses that extension and owns the migration; 18.0 also binds hooks/reactive flush to their runtime generation and drops an internal mutex in favour of self-cleaning databases. 18.1.0 adds initial Swift Package Manager support and RN 0.87 compatibility → on RN 0.87 this is the line to be on. 18.2.0 adds executeSync on a prepared statement (the synchronous path for a statement reused in a tight loop), stops applying the Kotlin plugin when AGP already provides it, and reverts a per-runtime PropNameID cache; 18.2.1 adds watchOS. 18.2.3 awaits transaction commit and preserves finalized handles, 18.2.4 fixes a use-after-free in the commit hook callback, 18.2.5 bumps sqlite/sqlcipher → take 18.2.5.
ASYNCSTORAGE V3 (verified vs the v3 migration docs): minimum RN 0.76 on iOS/Android (macOS 0.78, visionOS/windows 0.79); it is NOT New-Arch-only. getItem/setItem/removeItem/getAllKeys/clear keep their v2 signatures, multiGet is renamed getMany, and the default export still points to v2 storage → the move to createAsyncStorage is an incremental migration, not a cliff.
KEYCHAIN (2026-09-24, verified vs npm and GitHub): react-native-keychain has had no npm release since 10.0.0 on 2025-03-23 (18 months); the repo was last pushed 2026-04-29 and shows 194 open issues/PRs → pin it and re-check before starting new work on it.
MMKV WRITES: MMKV writes are synchronous and block the JS thread; the Calazans measurement (see reading) shows a worklet moves the ~29.56ms cost into serialization rather than removing it.
RB-E-P2P owns local-first persistence (the Hypercore/Autobase/Hyperbee log); RB-E-SECURITY owns secrets policy; RB-E-DATA owns server-state caching.
HISTORY: 2026-09-24 CORRECTED: the 18.2 PropNameID-cache revert ships in 18.2.0, not 18.2.1 (18.2.1 adds watchOS); op-sqlite pin 18.2.1 → 18.2.5, secure-store 57.0.3 → 57.0.4. 2026-09-10 REOPENED: op-sqlite 18.1.1 → 18.2.1 (executeSync on prepared statements), secure-store 57.0.2 → 57.0.3. 2026-08-18 REOPENED: op-sqlite 18.0.0 (2026-08-14) is breaking (crsqlite removed). 2026-07-06 CORRECTED: AsyncStorage v3 requires RN 0.76+ but is not New-Arch-only.
Canonical reading
Editorial annotations on why each piece matters — the articles themselves are the originals; read them there.
The library author walks the JSI/C++ internals that make op-sqlite fast and memory-light — lazy HostObject conversion, std::variant over a custom struct, key-sharing across result rows — with benchmarks. The mechanics of high-performance JSI SQLite, not API usage.
Builds the local-first reactive-persistence mental model from first principles — Collections, live queries via differential dataflow, optimistic transactional mutations — contrasting it with request/response fetching. The shift to sync-engine thinking.
A measured NEGATIVE result, which is why it is worth holding: MMKV writes are synchronous and block the JS thread, so moving them into a worklet looks like free relief. It isn't. Moving just the write left the ~29.56ms cost intact — it simply moved from the write into createSerializableString, because copying the string into the worklet costs what the write did. Sending the raw store object instead was worse (~132ms, cloning every property one at a time via cloneObjectProperties/clonePlainJSObject), and Bundle Mode changed nothing (~136ms), confirming the cost tracks data-structure size rather than compilation. The durable rule: a worklet is not a free way to move work off the JS thread — you pay serialization at the boundary, so measure that cost first and send the smallest payload you can.
The reviewed long-form essay behind this entry — the why, not a how-to. Also on GitHub.
About on-device storage & persistence
Diataxis: Explanation. This page builds understanding of how to place data on the device —
the reasoning behind the picks. It is not an API guide or a migration runbook. Local-first /
P2P persistence (Hypercore, Autobase, Hyperbee) is owned by RB-E-P2P; secrets policy depth by
RB-E-SECURITY; server-state caching by RB-E-DATA. Read this for the why.
The one principle that organises everything: pick storage by data shape × secrecy
"Where do I store this?" is really three different questions wearing one trenchcoat: small
key-value (prefs, flags, last-seen timestamps), relational or large structured data (anything you
query), and secrets (tokens, credentials). Each has a different home because each optimizes a
different thing — read latency, queryability, and OS-level protection respectively. The classic
failure is letting one tool answer all three: AsyncStorage as prefs store, database, and token
vault. It's genuinely fine at the first job, wrong for the second, and the recommendation is
explicit about the third — never for secrets. Classify by shape and secrecy first; the tool
picks itself.
The default, and why
Small key-value → react-native-mmkv (fast, synchronous). Relational/large → op-sqlite (perf) or
expo-sqlite (Expo first-party). Secrets/tokens → the OS Keychain/Keystore, never AsyncStorage:
expo-secure-store on Expo, react-native-keychain on bare RN.
Three shapes, three homes. MMKV wins small key-value because it's fast and synchronous — no
await ceremony for a boolean. SQLite wins structured data because "relational/large" is a
query problem, and op-sqlite attacks it with high-perf JSI. Keychain/Keystore wins secrets
because secrecy is an OS-protection problem, not a persistence problem — which is why the default
ends with "never AsyncStorage" for tokens. AsyncStorage isn't banished: for simple persisted
prefs with no perf concern, the when-clause says it's fine.
The landscape, and when each piece earns its place
@react-native-async-storage/async-storage — simple async key-value. v3.x moved to
instance-based storage (createAsyncStorage) with getMany/setMany/removeMany, and requires
RN 0.76+; the v2 default export is kept for back-compat. Latest is 3.1.x. Earns its place for
simple persisted prefs when perf isn't a concern.
react-native-mmkv (v4) — fast synchronous key-value, the common AsyncStorage replacement.
v4 is a full Nitro Modules rewrite (Oct 2025) that restored Old-Architecture compatibility.
Latest is 4.3.x. Earns its place the moment you have frequent synchronous reads.
op-sqlite / expo-sqlite — relational SQLite for structured/queryable data; op-sqlite is the
high-perf JSI option (latest 18.2.5, 2026-09-20). The 18.0.0 major (2026-08-14) removed
crsqlite, so an app using op-sqlite as the local half of a CRDT-synced store has to migrate that
extension away before upgrading; 18.1 added RN 0.87 compatibility. The author's own deep-dive explains why it's fast
and memory-light: lazy HostObject conversion, std::variant over a custom struct, key-sharing
across result rows — with benchmarks.
expo-secure-store / react-native-keychain — secure storage for tokens and secrets, backed by
the platform Keychain/Keystore. Earns its place for every credential, unconditionally. On Expo
the default is expo-secure-store (first-party, released with the SDK). react-native-keychain
is the bare-RN store and the pick for finer access-control/biometry options, but it has had no
npm release since 10.0.0 (2025-03-23): pin it and re-check before new work on it.
WatermelonDB — detected but not a default pick: @nozbe/watermelondb 0.28.0 (2025-04-07) is
still its latest npm release. Keep it where it is already in use; new relational work goes to
op-sqlite or expo-sqlite.
The P2P/local-first log — in a Hypercore/Autobase app, the primary persistence is the
Hypercore/Autobase/Hyperbee log itself, not KV or SQLite; KV like AsyncStorage is just for local
prefs there. That whole world is RB-E-P2P's.
Tradeoffs and failure modes to name out loud
Tokens in AsyncStorage. The one "never" in the default. Secrets need Keychain/Keystore
protection; a key-value store is the wrong trust boundary no matter how convenient.
AsyncStorage as a database. Structured/queryable data belongs in SQLite. Key-value stores
answer "get me X by key", not queries.
Misreading the AsyncStorage v3 requirements. v3.x requires RN 0.76+ — but it is not
New-Arch-only; that earlier claim was wrong (verified against the v3 migration docs). The v2
default export is kept for back-compat, so the instance-based createAsyncStorage move is a
migration, not a cliff.
Avoiding MMKV v4 over Old-Arch fears. Backwards: the v4 Nitro rewrite is what restored
Old-Arch compat.
Moving MMKV writes into a worklet to unblock JS. MMKV writes are synchronous, but a worklet
moves the cost into serialization at the boundary rather than removing it (measured ~29.56ms
either way).
Giving a local-first app a "main database". In P2P apps the log is the source of truth;
making SQLite/KV primary builds a second, divergent one. KV is for prefs only there.
Persisting like it's still request/response. The TanStack DB reading marks the shift to
sync-engine thinking — Collections, live queries via differential dataflow, optimistic
transactional mutations — a different mental model from fetch-then-cache.
How it interacts with the rest of the stack
P2P (RB-E-P2P). The when-clause draws the line: local-first apps persist primarily in the
Hypercore/Autobase/Hyperbee log; this entry's tools shrink to a prefs sidecar.
Security (RB-E-SECURITY). The secrets row is where the two entries meet — Keychain/
Keystore is an OS security boundary, and "never AsyncStorage" for tokens is as much a security
rule as a storage one.
Data layer (RB-E-DATA). On-device persistence is the other half of the data story:
server-state caching lives there, and the TanStack DB reading contrasts local-first reactive
persistence with request/response fetching — the boundary both entries share.
In one paragraph
On-device storage is three problems, not one — classify by data shape × secrecy and each
picks its own home: small key-value goes to react-native-mmkv (fast, synchronous; v4 is a
Nitro rewrite that restored Old-Arch compat), relational/large data goes to op-sqlite or
expo-sqlite (op-sqlite for high-perf JSI), and secrets go to the OS Keychain/Keystore (expo-secure-store on Expo, react-native-keychain
on bare RN) — never AsyncStorage. AsyncStorage itself remains fine for simple persisted prefs (v3.x is
instance-based and needs RN 0.76+, with the v2 export kept for back-compat), and in P2P
local-first apps the whole question shifts: the Hypercore/Autobase/Hyperbee log is the primary
persistence, and everything on this page is just the prefs drawer.
See also: RB-E-P2P (the log as primary persistence in local-first apps), RB-E-SECURITY
(secrets belong to the OS boundary), RB-E-DATA (server-state caching; sync-engine thinking vs
request/response).