Persistence is one of those decisions you make at the start of a project and rarely revisit — which is exactly why it's worth getting right. AsyncStorage is the default, MMKV is the modern alternative, and SecureStore / Keychain is what you actually need for tokens. On Soul33 we used all three for different data, and I've seen enough mistakes to write this down.
The numbers
AsyncStorage is asynchronous, JSON-only, and crosses the bridge for every read and write. On Android it's backed by SQLite; on iOS, by a serialized dictionary file. MMKV is synchronous, supports primitives + Buffers natively, and uses memory-mapped IO — meaning reads return at C++ speed without parsing JSON. In our benchmarks reading 1000 small keys: AsyncStorage ~310ms, MMKV ~6ms. For a single get, the difference is invisible; for a redux-persist rehydration on app cold start, it's the difference between a flash of empty UI and instant content.
When AsyncStorage is the right call
When you only read state at boot (a couple of dispatches in your redux-persist hydration), AsyncStorage is fine. It's already in 90% of RN apps, its API is well known, and it ships with most templates. If you're building a small app or an MVP, the migration cost to MMKV isn't worth the perf you'll never measure. Default to AsyncStorage; reach for MMKV when there's a real reason.
When MMKV pays off
Three signals push you to MMKV. First: many small reads on a hot path — feature flags read inside renderItem, A/B test buckets checked per screen, draft-state autosaves while typing. Second: large persisted state — Redux Persist payloads over ~500KB take AsyncStorage hundreds of milliseconds to rehydrate, with a visible loading flash on cold start. Third: structured data with shape that doesn't fit JSON neatly — counters, sets, binary blobs.
import { MMKV } from 'react-native-mmkv';
export const storage = new MMKV({
id: 'soul33-default',
// Optional encryption — see security note below
});
export const kv = {
getString: (k: string) => storage.getString(k) ?? null,
setString: (k: string, v: string) => storage.set(k, v),
getBool: (k: string) => storage.getBoolean(k) ?? false,
setBool: (k: string, v: boolean)=> storage.set(k, v),
getJSON: <T>(k: string): T | null => {
const raw = storage.getString(k);
if (!raw) return null;
try { return JSON.parse(raw) as T; } catch { return null; }
},
setJSON: (k: string, v: unknown)=> storage.set(k, JSON.stringify(v)),
remove: (k: string) => storage.delete(k),
clear: () => storage.clearAll(),
};Migrating an existing app without losing data
Don't do a hard cutover. Migrate on demand: on app start, for each MMKV key the app needs, check MMKV first; if absent, read from AsyncStorage, write to MMKV, then return the value. After a few weeks you can ship a cleanup that wipes the legacy keys. This pattern survived a real user base on Soul33 with zero reports of lost preferences during the swap.
import AsyncStorage from '@react-native-async-storage/async-storage';
import { storage } from './mmkv';
export async function readWithMigration(key: string): Promise<string | null> {
const mmkvVal = storage.getString(key);
if (mmkvVal !== undefined) return mmkvVal;
const legacy = await AsyncStorage.getItem(key);
if (legacy !== null) {
storage.set(key, legacy);
return legacy;
}
return null;
}What MMKV is not — secure storage
Neither MMKV nor AsyncStorage are secure. On a rooted/jailbroken device, both are trivially readable. Auth tokens, refresh tokens, biometric secrets, and anything regulated belongs in Keychain (iOS) / Keystore (Android) — accessible via react-native-keychain or expo-secure-store. MMKV supports symmetric encryption with a passed key, which is better than plaintext but still requires the key to live somewhere — typically Keychain. Use that combination only when you need encrypted bulk data; for individual secrets, Keychain alone is fine.
Redux Persist + MMKV — the real win
redux-persist defaults to AsyncStorage. Swapping the storage adapter to MMKV via redux-persist-mmkv-storage cut Soul33's hydration time from ~280ms to under 20ms on cold start — the difference between a perceptible flash of empty home screen and the app simply being ready. If you only do one MMKV migration, do this one.
Quick decision tree: tokens / passwords / biometrics → Keychain. Large persisted Redux state, hot-path reads, draft autosaves → MMKV. Small preferences, settings flags, last-route — either works, default to whatever the rest of the app uses. Don't introduce a third persistence layer unless there's a real reason.