TL;DR
For most React Native apps in 2026, the decision is not one winner. Use react-native-mmkv for fast app state, preferences, feature flags, small caches, and Zustand/Jotai persistence; use expo-secure-store for auth tokens, refresh tokens, API keys, and other small secrets; keep AsyncStorage only when you need the simplest async key-value baseline, Expo Go compatibility, or a low-risk migration path for an older app.
The practical rule is: MMKV for speed, SecureStore for secrets, AsyncStorage for compatibility. MMKV's official README describes fully synchronous calls, multiple instances, encryption support, JSI/NitroModule bindings, and a published benchmark of roughly 30x faster than AsyncStorage. AsyncStorage's current official GitHub docs describe it as asynchronous, unencrypted, persistent key-value storage and note that v3 adds createAsyncStorage scoped storage while preserving the legacy default export. Expo SecureStore's docs describe encrypted local key-value storage backed by platform secure storage on iOS and Android, but warn that large payloads can be rejected and that roughly 2 KB iOS limits have existed historically.
| Storage choice | Best use | Do not use it for |
|---|---|---|
| react-native-mmkv | Fast reads/writes, render-adjacent preferences, persisted client state, feature flags, offline queues | Raw secrets unless the encryption key is protected outside the JS bundle |
| AsyncStorage | Simple async persistence, old codebases, Expo Go-friendly examples, compatibility-first storage | Performance-critical hydration or sensitive credentials |
| expo-secure-store | Tokens, refresh tokens, API keys, biometric-gated secrets, device-bound keys | Large JSON caches, bulk offline state, hot render-path reads |
Key takeaways
- Choose MMKV for user-visible responsiveness. Synchronous reads are useful when a theme, onboarding flag, cart, or navigation state needs to be available before rendering.
- Choose SecureStore for credentials. Tokens belong in platform secure storage, not in AsyncStorage and not in a hardcoded MMKV encryption key.
- Choose AsyncStorage when compatibility beats speed. It is still a useful baseline for simple async storage and incremental migrations, especially when existing libraries already expect its API.
- Do not treat MMKV encryption as a token-storage replacement by itself. If the key is embedded in JavaScript, the protection is weak. Store the MMKV key in SecureStore if you encrypt larger sensitive data.
- Keep large values out of SecureStore. Expo explicitly warns that large payloads can be rejected by the underlying platform.
- Update older AsyncStorage examples carefully. Newer official docs introduce
createAsyncStorage("database-name"); the default export remains a backward-compatible singleton for v2-style code.
The fast decision tree
Are you storing auth tokens, refresh tokens, API keys, or private keys?
→ Use expo-secure-store.
Are you storing theme, locale, flags, persisted Zustand/Jotai state, cart state, or small offline queues?
→ Use react-native-mmkv.
Are you in Expo Go, maintaining old AsyncStorage code, or integrating a library that only accepts async storage?
→ Use AsyncStorage, then migrate hot paths to MMKV later.
Are you storing large files, photos, blobs, SQLite-sized data, or hundreds of MB of cache?
→ Use expo-file-system, SQLite, WatermelonDB, Realm, or a real database instead.
Comparison table
| Feature | AsyncStorage | react-native-mmkv | expo-secure-store |
|---|---|---|---|
| API shape | Promise-based async key-value API | Synchronous key-value API plus hooks | Promise-based secure key-value API |
| Official positioning | Asynchronous, unencrypted persistent storage | Fast direct native C++ bindings for React Native | Encrypted local key-value storage |
| Best fit | Compatibility and simple persistence | Performance-sensitive app state | Small sensitive secrets |
| Expo support | Works in Expo projects and Expo Go-style examples | Expo install plus prebuild/development build because it is a native module | Included in Expo Go, with caveats for requireAuthentication |
| Encryption | No built-in encryption | MMKV file encryption available; key management is your responsibility | Uses platform secure storage on iOS/Android |
| Multiple stores | v3 supports scoped storage via createAsyncStorage | Multiple instances via IDs | Separate keys, not multiple high-volume stores |
| Data types | Strings; serialize objects manually | Strings, booleans, numbers, buffers; serialize objects manually | Strings/values intended to stay small |
| Render-path reads | Avoid; async hydration can cause loading states | Good fit; synchronous reads avoid await | Avoid; secure storage calls are async and slower |
| Migration difficulty | Baseline/legacy option | Requires native setup and call-site updates | Requires async token-access flow and error handling |
AsyncStorage: compatibility baseline, not the performance choice
AsyncStorage is still worth knowing because a lot of React Native libraries, examples, and older apps use it. The current official README describes it as an asynchronous, unencrypted, persistent key-value storage solution for React Native. Its API is easy to understand: store strings with setItem, read them with getItem, and serialize objects yourself.
import AsyncStorage from "@react-native-async-storage/async-storage";
await AsyncStorage.setItem("theme", "dark");
await AsyncStorage.setItem("user", JSON.stringify({ id: "123", name: "Alice" }));
const theme = await AsyncStorage.getItem("theme");
const userJson = await AsyncStorage.getItem("user");
const user = userJson ? JSON.parse(userJson) : null;
await AsyncStorage.multiSet([
["locale", "en-US"],
["notifications", "true"],
]);
const entries = await AsyncStorage.multiGet(["theme", "locale"]);
What's changed in current AsyncStorage docs
Older guides usually show only the default export. Current official GitHub docs also document createAsyncStorage, which creates a named scoped storage instance. That matters for apps that want separate databases per user, workspace, or feature.
import { createAsyncStorage } from "@react-native-async-storage/async-storage";
const userStorage = createAsyncStorage("user-1234");
await userStorage.setItem("preferences", JSON.stringify({ theme: "dark" }));
const raw = await userStorage.getItem("preferences");
const preferences = raw ? JSON.parse(raw) : null;
The default export is still useful for backward-compatible v2-style integrations:
import AsyncStorage from "@react-native-async-storage/async-storage";
await AsyncStorage.getItem("legacy-key");
When AsyncStorage is still the right choice
Use AsyncStorage when:
- your app already uses it and storage is not on a hot path;
- you need a simple Promise-based adapter for libraries such as Redux Persist or Zustand's async storage adapter;
- Expo Go compatibility matters more than startup speed;
- you want to migrate gradually before introducing a native MMKV dependency.
Do not use AsyncStorage for auth tokens or secrets. The official docs call it unencrypted, and Expo's own data-storage guidance describes AsyncStorage as a good choice for data that does not need encryption, such as preferences or app state.
react-native-mmkv: the default for fast local app state
react-native-mmkv is the performance option. The official README describes fully synchronous calls, no async/await or promises, direct native C++ bindings, encryption support, multiple instances, JSI/NitroModule support, and a benchmark of roughly 30x faster than AsyncStorage.
Current MMKV examples use createMMKV:
import { createMMKV } from "react-native-mmkv";
export const storage = createMMKV();
storage.set("theme", "dark");
storage.set("launchCount", 12);
storage.set("onboardingComplete", true);
const theme = storage.getString("theme");
const launchCount = storage.getNumber("launchCount");
const onboardingComplete = storage.getBoolean("onboardingComplete");
storage.remove("theme");
const keys = storage.getAllKeys();
Namespaced MMKV instances
MMKV is a strong fit when you want to isolate app-wide settings, per-user state, and cache-like data without making every caller think about file paths.
import { createMMKV } from "react-native-mmkv";
export const appStorage = createMMKV({ id: "app-storage" });
export const cacheStorage = createMMKV({ id: "cache-storage" });
export const userStorage = createMMKV({ id: "user-1234-storage" });
cacheStorage.clearAll();
MMKV with Zustand persist
MMKV pairs well with Zustand because the storage adapter can hydrate synchronously. That removes an entire class of "wait for storage before rendering" code in preferences, carts, and feature-flag stores.
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { storage } from "@/lib/storage";
type CartItem = { id: string; quantity: number };
type CartState = {
items: CartItem[];
addItem: (item: CartItem) => void;
};
const mmkvStorage = createJSONStorage(() => ({
setItem: (name: string, value: string) => storage.set(name, value),
getItem: (name: string) => storage.getString(name) ?? null,
removeItem: (name: string) => storage.remove(name),
}));
export const useCartStore = create<CartState>()(
persist(
(set) => ({
items: [],
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
}),
{
name: "cart-storage",
storage: mmkvStorage,
},
),
);
MMKV encryption and key management
MMKV supports encrypted storage, but encryption is only as strong as the key-management pattern. If the encryption key is hardcoded in the JavaScript bundle, a motivated attacker can extract it. A safer pattern is to generate a random key, keep that key in SecureStore, and use it to initialize or encrypt MMKV.
import * as SecureStore from "expo-secure-store";
import { createMMKV } from "react-native-mmkv";
const MMKV_KEY = "mmkv-encryption-key";
async function getOrCreateEncryptionKey() {
const existing = await SecureStore.getItemAsync(MMKV_KEY);
if (existing) return existing;
const key = generateRandomKey(64); // Use expo-random, react-native-get-random-values, or your approved crypto helper.
await SecureStore.setItemAsync(MMKV_KEY, key);
return key;
}
export async function createEncryptedStorage() {
const encryptionKey = await getOrCreateEncryptionKey();
return createMMKV({
id: "encrypted-app-storage",
encryptionKey,
encryptionType: "AES-256",
});
}
Use this for larger sensitive local datasets where SecureStore is too small or too slow. For normal auth tokens, SecureStore alone is simpler and easier to audit.
expo-secure-store: the credential store
Expo SecureStore is the right default for auth tokens, refresh tokens, API keys, private keys, and other small values that should not be stored in plaintext. The Expo docs describe it as a way to encrypt and securely store key-value pairs locally on the device. On Android, values are stored in SharedPreferences encrypted with Android's Keystore system. On iOS, values are stored using Keychain services as kSecClassGenericPassword.
import * as SecureStore from "expo-secure-store";
await SecureStore.setItemAsync("access_token", accessToken);
await SecureStore.setItemAsync("refresh_token", refreshToken);
const token = await SecureStore.getItemAsync("access_token");
await SecureStore.deleteItemAsync("access_token");
await SecureStore.deleteItemAsync("refresh_token");
SecureStore options worth caring about
await SecureStore.setItemAsync("refresh_token", refreshToken, {
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
});
await SecureStore.setItemAsync("private_key", privateKey, {
requireAuthentication: true,
authenticationPrompt: "Unlock your key",
});
Use WHEN_UNLOCKED_THIS_DEVICE_ONLY when a credential should stay tied to the current device rather than moving through backup/restore flows. Use requireAuthentication only after testing the actual Expo/client environment because Expo documents an Expo Go caveat for biometric authentication.
SecureStore is not a cache
SecureStore is for small secrets, not large offline payloads. Expo warns that large payloads can be rejected by the underlying platform and notes that some iOS releases historically refused values above roughly 2048 bytes. If you need to persist a large object securely, store a SecureStore-protected encryption key and put the encrypted payload in MMKV, SQLite, or a file/database layer designed for larger values.
What to store where
Access token
→ expo-secure-store
Refresh token
→ expo-secure-store, often with WHEN_UNLOCKED_THIS_DEVICE_ONLY
MMKV encryption key
→ expo-secure-store
Theme, locale, onboarding flag
→ react-native-mmkv
Persisted Zustand/Jotai state
→ react-native-mmkv
Feature flags and remote-config cache
→ react-native-mmkv
Small API response cache
→ react-native-mmkv if read often; AsyncStorage if compatibility matters
Draft form state
→ react-native-mmkv
Large files, images, SQLite-like data, document caches
→ expo-file-system, SQLite, Realm, WatermelonDB, or another data store
Migration strategy: AsyncStorage to MMKV
The safest production migration is a one-time copy plus a completion flag. Do not replace every await AsyncStorage.getItem call blindly and hope the app behaves the same. AsyncStorage is async; MMKV is sync. Hydration timing changes, which is usually a benefit but can expose assumptions in startup code.
import AsyncStorage from "@react-native-async-storage/async-storage";
import { createMMKV } from "react-native-mmkv";
const storage = createMMKV();
const MIGRATED_KEY = "async-storage-migrated-v1";
export async function migrateAsyncStorageToMMKV() {
if (storage.getBoolean(MIGRATED_KEY)) return;
const keys = await AsyncStorage.getAllKeys();
const entries = await AsyncStorage.multiGet(keys);
for (const [key, value] of entries) {
if (value !== null) {
storage.set(key, value);
}
}
storage.set(MIGRATED_KEY, true);
}
Run the migration early in app startup, then update call sites deliberately:
// Before
const theme = await AsyncStorage.getItem("theme");
// After
const theme = storage.getString("theme") ?? "system";
Keep a rollback window. For the first release, copy values into MMKV and leave AsyncStorage intact. After production telemetry shows the migration is stable, ship a second release that removes the old AsyncStorage writes and clears legacy keys.
Security model: what "secure" means here
AsyncStorage is unencrypted. It is fine for non-sensitive preferences and app state, but not for credentials, tokens, or regulated personal data.
MMKV can encrypt its storage file. That protects the file at rest, but only if the key is protected. A hardcoded JavaScript key is not enough. A SecureStore-backed key is a stronger pattern for larger encrypted local datasets.
SecureStore delegates to platform secure storage. That is the best fit for secrets, but it comes with async access, platform-specific behavior, and size limits. Design token access around startup/session boundaries instead of calling SecureStore repeatedly during render.
React Native New Architecture implications
The New Architecture strengthens the MMKV case because MMKV is built around direct native bindings rather than the old serialized bridge pattern. Its synchronous reads are most valuable for values needed immediately: theme, language, feature flags, session hints, and persisted UI state.
AsyncStorage remains async even when the app runs on newer React Native architecture. That does not make it bad; it means you should keep it away from hot render paths. SecureStore is async by design because secure platform storage involves OS-level calls and optional authentication.
Source notes
Sources checked on 2026-05-15:
- react-native-mmkv official GitHub README:
https://github.com/mrousavy/react-native-mmkv - react-native-mmkv npm registry metadata:
https://registry.npmjs.org/react-native-mmkv - AsyncStorage official GitHub README and docs source:
https://github.com/react-native-async-storage/async-storage, especiallydocs/api/usage.mdanddocs/faq.md - AsyncStorage npm registry metadata:
https://registry.npmjs.org/@react-native-async-storage%2fasync-storage - Expo SecureStore docs:
https://docs.expo.dev/versions/latest/sdk/securestore/ - Expo data-storage guidance:
https://docs.expo.dev/develop/user-interface/store-data/
One older AsyncStorage public docs path (react-native-async-storage.github.io/async-storage/) returned 404 during refresh, so this guide cites the maintained GitHub source/docs and package registry metadata instead of relying on that stale URL.
Related: Zustand vs Jotai vs Nano Stores for the state management layer that often persists into MMKV or AsyncStorage, Gluestack UI vs React Native Paper vs Unistyles for React Native UI stack decisions, and NativeWind vs Tamagui vs twrnc for styling choices in the same app architecture.
See also: React vs Vue and React vs Svelte.
