react-brain
Browse decisions
React & language foundations 3App architecture 11UI 13Platform & native 9Build, test, observe, secure 6AI in React apps 3stack composerdecision recordscensusstaleness benchnewsletterarchitecturechangelogmethodologyroadmap

entriesplatform-native · verified 2026-09-24 · react-native

On-device storage & persistence

reviewedconfidence: mediumtrack: weakenedthis tier holds 71% on the public scorecard (7/20 graded · 0 overturned) →

related decisions: p2p · security · data

cited by: auth · security · ondevice-ai

re-verified 8× — 2026-09-24 · 2026-09-21 · 2026-09-10 · 2026-09-01 · 2026-08-18 · 2026-08-04 +2 · changelog

recommendation

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.

  • simple persisted prefs, no perf concern → AsyncStorage (@react-native-async-storage/async-storage 3.x; needs RN 0.76+)
  • frequent synchronous reads → MMKV (react-native-mmkv v4)
  • 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

optiontradeoffevidence
@react-native-async-storage/async-storageSimple 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-sqliteRelational 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-storeSecrets/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.594k/wk · ships in 9/14

evidence: npm weekly downloads (signals snapshot) · “ships in n/D” = adoption across the production-app census, honest denominators

npm weekly downloads (from the corpus's last signals run): react-native-mmkv 1.7M · @react-native-async-storage/async-storage 9.0M · @op-engineering/op-sqlite 880k · expo-sqlite 1.9M · react-native-keychain 594k · expo-secure-store 7.8M · @nozbe/watermelondb 88k

Verified notes

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.

SQLite for React Native, but 5x faster and 5x less memoryOscar Franco (op-sqlite author)

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.

An Interactive Guide to TanStack DBMaxi Ferreira (Frontend at Scale)

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.

Why We Did Not Move MMKV Writes to a Worklet: The Serialization CostAndrei Calazans

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.

Sources

The full explanation

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).

Related in platform-native: native · media · native-ui · keyboard · payments · brownfield · games · alt-frameworks