TL;DR
Every React Native app needs persistent local storage — user preferences, cached data, auth tokens, offline state. AsyncStorage is the built-in default — async key-value store backed by SQLite/SharedPreferences, simple API, but slow (everything is async, no synchronous reads). react-native-mmkv is the performance upgrade — C++ implementation of WeChat's MMKV, synchronous reads, 10x faster than AsyncStorage, optional AES encryption, built-in TypeScript support. expo-secure-store uses the platform's secure enclave — iOS Keychain and Android Keystore, hardware-backed encryption for sensitive data like auth tokens and API keys. For user preferences and app state: MMKV. For sensitive credentials (tokens, keys): expo-secure-store. For simple caching in older Expo Go setups: AsyncStorage.
Key Takeaways
- MMKV is 10x faster than AsyncStorage — synchronous reads, C++ implementation
- expo-secure-store uses hardware encryption — iOS Keychain, Android Keystore
- AsyncStorage is limited to 6MB by default on Android — MMKV and SecureStore have no practical size limits
- MMKV supports synchronous reads —
storage.getString("key")returns immediately - expo-secure-store is always async — Keychain operations can't be synchronous
- MMKV has optional AES encryption — encrypt the entire storage file with a key
- AsyncStorage is deprecated in Expo Go — React Native's own recommendation is to migrate away
Storage Use Cases
Auth tokens (JWT, refresh tokens)
→ expo-secure-store (hardware-encrypted, small data)
User preferences (theme, language, notifications)
→ react-native-mmkv (fast reads, frequently accessed)
Cached API responses (large JSON objects)
→ react-native-mmkv or AsyncStorage (size matters)
Draft data (unsent messages, forms)
→ react-native-mmkv (synchronous saves, fast)
Large files / binary data
→ expo-file-system (neither KV store is ideal)
Session state (cart, scroll position)
→ react-native-mmkv + Zustand persist middleware
AsyncStorage: The Standard Baseline
AsyncStorage is the React Native community's standard async key-value storage. It's well-supported, works everywhere including Expo Go, but has performance limitations.
Installation
npx expo install @react-native-async-storage/async-storage
Basic Usage
import AsyncStorage from "@react-native-async-storage/async-storage";
// Store data
await AsyncStorage.setItem("theme", "dark");
await AsyncStorage.setItem("user", JSON.stringify({ id: "123", name: "Alice" }));
// Read data
const theme = await AsyncStorage.getItem("theme");
const userJson = await AsyncStorage.getItem("user");
const user = userJson ? JSON.parse(userJson) : null;
// Remove
await AsyncStorage.removeItem("theme");
// Batch operations (more efficient than individual calls)
await AsyncStorage.multiSet([
["theme", "dark"],
["language", "en"],
["notifications", "true"],
]);
const [theme, language] = await AsyncStorage.multiGet(["theme", "language"]);
Wrapper with Type Safety
// Type-safe wrapper around AsyncStorage
class TypedStorage {
static async set<T>(key: string, value: T): Promise<void> {
await AsyncStorage.setItem(key, JSON.stringify(value));
}
static async get<T>(key: string): Promise<T | null> {
const item = await AsyncStorage.getItem(key);
if (item === null) return null;
try {
return JSON.parse(item) as T;
} catch {
return item as unknown as T;
}
}
static async remove(key: string): Promise<void> {
await AsyncStorage.removeItem(key);
}
}
// Usage
await TypedStorage.set("preferences", { theme: "dark", language: "en" });
const prefs = await TypedStorage.get<{ theme: string; language: string }>("preferences");
AsyncStorage with Zustand
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import AsyncStorage from "@react-native-async-storage/async-storage";
const storage = createJSONStorage(() => AsyncStorage);
export const usePreferencesStore = create(
persist(
(set) => ({
theme: "system" as "light" | "dark" | "system",
language: "en",
setTheme: (theme: "light" | "dark" | "system") => set({ theme }),
}),
{
name: "user-preferences",
storage,
}
)
);
react-native-mmkv: High-Performance Storage
MMKV is Tencent WeChat's production key-value store — it uses mmap for near-instant reads and writes, with optional AES encryption of the entire storage file.
Installation
npx expo install react-native-mmkv
# Requires Expo bare workflow or React Native CLI
# Does NOT work in Expo Go (requires native module)
Basic Usage (Synchronous)
import { MMKV } from "react-native-mmkv";
// Create storage instance
export const storage = new MMKV();
// Synchronous reads and writes — no await needed
storage.set("theme", "dark");
storage.set("userId", 12345);
storage.set("isLoggedIn", true);
storage.set("preferences", JSON.stringify({ language: "en" }));
// Synchronous reads
const theme = storage.getString("theme"); // Returns string | undefined
const userId = storage.getNumber("userId"); // Returns number | undefined
const isLoggedIn = storage.getBoolean("isLoggedIn"); // Returns boolean | undefined
// Delete
storage.delete("theme");
// Check existence
const hasTheme = storage.contains("theme");
// Get all keys
const allKeys = storage.getAllKeys(); // string[]
// Clear all
storage.clearAll();
Encrypted Storage
import { MMKV } from "react-native-mmkv";
// AES-256 encrypted storage
export const secureStorage = new MMKV({
id: "secure-storage",
encryptionKey: "your-32-character-encryption-key!!", // 32 chars for AES-256
});
// Or derive key from device-specific secret
import * as SecureStore from "expo-secure-store";
async function createEncryptedStorage() {
// Generate or retrieve encryption key from SecureStore
let key = await SecureStore.getItemAsync("mmkv-encryption-key");
if (!key) {
key = generateRandomKey(32);
await SecureStore.setItemAsync("mmkv-encryption-key", key);
}
return new MMKV({
id: "encrypted-app-storage",
encryptionKey: key,
});
}
Multiple Storage Instances
import { MMKV } from "react-native-mmkv";
// Separate namespaced storage instances
export const appStorage = new MMKV({ id: "app-storage" });
export const cacheStorage = new MMKV({ id: "cache-storage" });
export const userStorage = new MMKV({ id: "user-storage" });
// Clear cache without affecting app or user data
cacheStorage.clearAll();
MMKV with Zustand (Synchronous Persist)
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import { storage } from "@/lib/storage"; // MMKV instance
// Synchronous Zustand storage adapter for MMKV
const mmkvStorage = createJSONStorage(() => ({
setItem: (name: string, value: string) => storage.set(name, value),
getItem: (name: string) => storage.getString(name) ?? null,
removeItem: (name: string) => storage.delete(name),
}));
export const useCartStore = create(
persist(
(set, get) => ({
items: [] as CartItem[],
addItem: (item: CartItem) => set((state) => ({
items: [...state.items, item],
})),
total: () => get().items.reduce((sum, item) => sum + item.price, 0),
}),
{
name: "cart-storage",
storage: mmkvStorage,
}
)
);
Performance: MMKV vs AsyncStorage
Benchmark: 1,000 string write/read cycles
AsyncStorage:
- Write: ~2,500ms total (~2.5ms per op)
- Read: ~1,800ms total (~1.8ms per op)
MMKV:
- Write: ~45ms total (~0.045ms per op)
- Read: ~12ms total (~0.012ms per op)
MMKV is ~55x faster on writes, ~150x faster on reads
expo-secure-store: Hardware-Encrypted Storage
expo-secure-store uses the platform's secure hardware — iOS Keychain Services and Android Keystore — for storing sensitive values like auth tokens, API keys, and private data.
Installation
npx expo install expo-secure-store
# Works in Expo Go and bare workflow
Basic Usage
import * as SecureStore from "expo-secure-store";
// Store sensitive data (async, hardware-backed)
await SecureStore.setItemAsync("auth_token", accessToken);
await SecureStore.setItemAsync("refresh_token", refreshToken);
await SecureStore.setItemAsync("api_key", apiKey);
// Read
const token = await SecureStore.getItemAsync("auth_token");
if (!token) throw new Error("Not authenticated");
// Delete
await SecureStore.deleteItemAsync("auth_token");
// Check if available on device
const isAvailable = await SecureStore.isAvailableAsync();
Options: Accessibility and Biometrics
// Require biometric authentication to access the value
await SecureStore.setItemAsync("sensitive_key", value, {
requireAuthentication: true, // FaceID / TouchID / Biometric required
authenticationPrompt: "Verify your identity to access your data",
});
// Control when the value is accessible on iOS
await SecureStore.setItemAsync("token", value, {
keychainAccessible: SecureStore.WHEN_UNLOCKED, // Default
// SecureStore.AFTER_FIRST_UNLOCK — available after first unlock
// SecureStore.ALWAYS — always available (less secure)
// SecureStore.WHEN_PASSCODE_SET_THIS_DEVICE_ONLY
// SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY
});
Auth Token Management Pattern
// lib/auth-storage.ts — Centralized secure token storage
import * as SecureStore from "expo-secure-store";
const ACCESS_TOKEN_KEY = "access_token";
const REFRESH_TOKEN_KEY = "refresh_token";
const USER_ID_KEY = "user_id";
export const authStorage = {
async saveTokens(accessToken: string, refreshToken: string, userId: string) {
await Promise.all([
SecureStore.setItemAsync(ACCESS_TOKEN_KEY, accessToken),
SecureStore.setItemAsync(REFRESH_TOKEN_KEY, refreshToken),
SecureStore.setItemAsync(USER_ID_KEY, userId),
]);
},
async getAccessToken(): Promise<string | null> {
return SecureStore.getItemAsync(ACCESS_TOKEN_KEY);
},
async getRefreshToken(): Promise<string | null> {
return SecureStore.getItemAsync(REFRESH_TOKEN_KEY);
},
async clearTokens() {
await Promise.all([
SecureStore.deleteItemAsync(ACCESS_TOKEN_KEY),
SecureStore.deleteItemAsync(REFRESH_TOKEN_KEY),
SecureStore.deleteItemAsync(USER_ID_KEY),
]);
},
async isAuthenticated(): Promise<boolean> {
const token = await SecureStore.getItemAsync(ACCESS_TOKEN_KEY);
return token !== null;
},
};
Feature Comparison
| Feature | AsyncStorage | MMKV | expo-secure-store |
|---|---|---|---|
| Read performance | Slow (async) | ✅ Synchronous, fast | Slow (async) |
| Encryption | ❌ | ✅ Optional AES | ✅ Hardware (Keychain/Keystore) |
| Synchronous API | ❌ | ✅ | ❌ |
| Size limit (Android) | 6MB default | No limit | 2KB per key |
| Expo Go | ✅ | ❌ | ✅ |
| New Architecture | ✅ | ✅ | ✅ |
| Multiple instances | ❌ | ✅ | ❌ |
| Biometric lock | ❌ | ❌ | ✅ |
| Binary data | ❌ | ✅ | ❌ |
| GitHub stars | 4.5k | 8.5k | (Expo SDK) |
When to Use Each
Choose AsyncStorage if:
- Your app uses Expo Go (no native module compilation)
- Simple async key-value needs where performance isn't critical
- You're migrating an existing app incrementally and MMKV isn't yet worth the setup
Choose react-native-mmkv if:
- Performance matters — preferences read in component render, frequent writes
- You want synchronous access patterns without
useEffect/async complications - Replacing AsyncStorage in a Zustand or Jotai persist store
- You need multiple isolated storage namespaces
Choose expo-secure-store if:
- Storing auth tokens, API keys, refresh tokens, or any sensitive credentials
- Biometric authentication before accessing stored values is needed
- Hardware-backed encryption (Keychain/Keystore) is a compliance requirement
- Small amounts of sensitive data (< 2KB per key) — not for large objects
Security Model: What "Secure" Actually Means
The word "secure" means different things across these three libraries, and the distinctions matter for compliance and threat modeling.
AsyncStorage has no encryption. Data is stored in plain text in an SQLite database file on Android and in a plain JSON file on iOS. Any process with root access — or a forensic tool applied to an unencrypted backup — can read AsyncStorage data directly. For non-sensitive data (cached API responses, UI preferences) this is acceptable. For tokens, credentials, or personal data with compliance requirements, it is not.
MMKV encryption with encryptionKey encrypts the storage file using AES-256. The encryption key is stored in memory while the app is running, meaning the file is encrypted at rest. The critical caveat: the encryption key itself must be stored somewhere. If you hardcode it in your JavaScript bundle, it provides minimal protection — the key is extractable from the app binary. The right pattern is to generate a random key, store it in expo-secure-store, and retrieve it at app startup. This makes MMKV a secure-at-rest store, but the security depends entirely on expo-secure-store protecting the key.
expo-secure-store is architecturally different from the other two. It does not encrypt data itself — it delegates to the platform's hardware security module: iOS Keychain Services (backed by the Secure Enclave on modern iPhones) and Android Keystore (backed by the hardware security module or TEE). This means the key material never leaves the secure hardware, even if the app process is compromised. The iOS Keychain also participates in device backup and device-to-device transfer (with iCloud Keychain), which can be a feature or a compliance concern depending on your context. The WHEN_UNLOCKED_THIS_DEVICE_ONLY accessibility setting prevents Keychain items from syncing or being restored — use it when data must be device-specific.
The practical rule: always use expo-secure-store for credentials, never AsyncStorage. MMKV encrypted with a SecureStore-backed key is appropriate for larger sensitive datasets that exceed SecureStore's 2KB-per-key limit.
Migration Strategy: AsyncStorage to MMKV
Migrating from AsyncStorage to MMKV in a production app requires careful handling because MMKV's synchronous API and AsyncStorage's async API are not drop-in replacements. The safest pattern is a one-time migration on first launch:
Read all keys from AsyncStorage, write them to MMKV, then clear AsyncStorage. Mark the migration as complete in MMKV itself (mmkvStorage.set("migrated_v2", true)). On subsequent launches, check the migration flag first and skip the migration step. This approach is safe to run on any launch because the completion flag prevents double-migration, and it handles the case where the migration process is interrupted (the app closes mid-migration) by allowing it to restart.
The API shape difference requires updating every call site: await AsyncStorage.getItem("key") becomes storage.getString("key") (synchronous, no await). Zustand's persist middleware adapter (shown in the code examples above) handles this automatically once the adapter is updated — the store's initial hydration changes from async to synchronous, which can be a meaningful improvement for stores that initialize component state.
MMKV and the New Architecture
React Native's New Architecture (default as of React Native 0.76) replaces the JSON Bridge with JSI (JavaScript Interface). MMKV was one of the first community libraries to ship JSI support, and the performance difference is significant: the 10x benchmark against AsyncStorage assumes the old Bridge architecture. Under JSI, MMKV's synchronous reads operate through a direct C++ function call from JavaScript with zero serialization overhead. For stores accessed during component render (navigation state, theme, feature flags), the latency reduction from MMKV on the New Architecture is measurable in frame rate, not just artificial benchmarks.
AsyncStorage under the New Architecture still uses async calls, so its relative disadvantage to MMKV is maintained. expo-secure-store's async nature is fundamental to how hardware security modules work — the TEE communication is inherently asynchronous — so it does not benefit from the synchronous advantages of JSI. For auth token retrieval (done once at app startup), this is not a practical concern.
Methodology
Data sourced from the react-native-mmkv GitHub repository and documentation (github.com/mrousavy/react-native-mmkv), AsyncStorage documentation (react-native-async-storage.github.io), expo-secure-store documentation (docs.expo.dev/versions/latest/sdk/securestore), performance benchmarks from Marc Rousavy's MMKV blog posts, and community discussions in the Expo Discord. GitHub star counts as of February 2026.
Related: Zustand vs Jotai vs Nano Stores for the state management layer that uses these storage solutions for persistence, or NativeWind vs Tamagui vs twrnc for other React Native tooling decisions.
See also: React vs Vue and React vs Svelte