Navigation: ← Push Notifications | Native Modules & Bridge →
Mobile apps need to persist data locally for offline access, caching, user preferences, and performance. React Native offers multiple storage solutions ranging from simple key-value stores to full relational databases. Choosing the right storage depends on data complexity, performance requirements, security needs, and offline capabilities.
This guide covers the storage solutions and patterns you'll encounter in React Native interviews from mid-level through architect roles.
| Solution | Type | Best For | Performance |
|---|---|---|---|
| AsyncStorage | Key-value (async) | User prefs, small data | Slow (JS bridge) |
| MMKV | Key-value (sync) | Fast prefs, session data | 10-30x faster than AsyncStorage |
| SQLite | Relational DB | Structured data, queries | Good with indexing |
| Realm | Object DB | Complex objects, sync | Excellent (lazy loading) |
| Secure Storage | Encrypted KV | Tokens, credentials | Moderate |
Built into React Native community packages (@react-native-async-storage/async-storage). Simple async key-value API. All data is stored as strings — JSON serialization is manual. Not encrypted. Suitable for non-sensitive, small data.
Built by WeChat, uses memory-mapped files for synchronous, high-performance key-value storage. Supports encryption. Ideal replacement for AsyncStorage when speed matters.
Relational database via expo-sqlite, react-native-quick-sqlite, or @op-engineering/op-sqlite. Supports SQL queries, transactions, indexes, and migrations. Best for structured data with relationships.
Mobile-first object database (now owned by MongoDB). Schema-based with live objects, lazy loading, and optional MongoDB Atlas sync. Zero-copy architecture for performance.
Platform-native encrypted storage:
- iOS Keychain — Hardware-backed encryption on supported devices
- Android Keystore — Hardware-backed key storage
Use react-native-keychain or expo-secure-store for tokens, passwords, and sensitive credentials.
Offline-first apps treat local storage as the primary data source and sync with the server in the background. Patterns include:
- Cache-then-network — Show cached data immediately, update from server
- Queue-based sync — Queue mutations offline, replay when online
- Conflict resolution — Last-write-wins, CRDTs, or server-authoritative
import AsyncStorage from '@react-native-async-storage/async-storage';
const STORAGE_KEYS = {
USER_PREFERENCES: '@app/user_preferences',
ONBOARDING_COMPLETE: '@app/onboarding_complete',
} as const;
interface UserPreferences {
theme: 'light' | 'dark' | 'system';
language: string;
notificationsEnabled: boolean;
}
export const storage = {
async getPreferences(): Promise<UserPreferences | null> {
try {
const json = await AsyncStorage.getItem(STORAGE_KEYS.USER_PREFERENCES);
return json ? (JSON.parse(json) as UserPreferences) : null;
} catch (error) {
console.error('Failed to read preferences:', error);
return null;
}
},
async setPreferences(prefs: UserPreferences): Promise<void> {
await AsyncStorage.setItem(
STORAGE_KEYS.USER_PREFERENCES,
JSON.stringify(prefs),
);
},
async clear(): Promise<void> {
const keys = Object.values(STORAGE_KEYS);
await AsyncStorage.multiRemove(keys);
},
};import { MMKV } from 'react-native-mmkv';
export const mmkv = new MMKV({
id: 'app-storage',
encryptionKey: 'your-encryption-key-here',
});
interface StorageAdapter {
getString: (key: string) => string | undefined;
set: (key: string, value: string | number | boolean) => void;
delete: (key: string) => void;
getAllKeys: () => string[];
}
export const fastStorage: StorageAdapter = {
getString: (key) => mmkv.getString(key),
set: (key, value) => mmkv.set(key, value),
delete: (key) => mmkv.delete(key),
getAllKeys: () => mmkv.getAllKeys(),
};
// Zustand persist middleware adapter
import { StateStorage } from 'zustand/middleware';
export const mmkvStorage: StateStorage = {
getItem: (name) => mmkv.getString(name) ?? null,
setItem: (name, value) => mmkv.set(name, value),
removeItem: (name) => mmkv.delete(name),
};import * as SQLite from 'expo-sqlite';
const db = SQLite.openDatabaseSync('app.db');
const MIGRATIONS = [
`CREATE TABLE IF NOT EXISTS notes (
id TEXT PRIMARY KEY NOT NULL,
title TEXT NOT NULL,
body TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
synced INTEGER DEFAULT 0
)`,
`CREATE INDEX IF NOT EXISTS idx_notes_updated ON notes(updated_at DESC)`,
];
export function runMigrations(): void {
db.withTransactionSync(() => {
for (const migration of MIGRATIONS) {
db.execSync(migration);
}
});
}
export interface Note {
id: string;
title: string;
body: string;
createdAt: number;
updatedAt: number;
synced: boolean;
}
export const noteRepository = {
getAll(): Note[] {
const rows = db.getAllSync<{
id: string;
title: string;
body: string;
created_at: number;
updated_at: number;
synced: number;
}>('SELECT * FROM notes ORDER BY updated_at DESC');
return rows.map((row) => ({
id: row.id,
title: row.title,
body: row.body,
createdAt: row.created_at,
updatedAt: row.updated_at,
synced: row.synced === 1,
}));
},
insert(note: Note): void {
db.runSync(
'INSERT INTO notes (id, title, body, created_at, updated_at, synced) VALUES (?, ?, ?, ?, ?, ?)',
[note.id, note.title, note.body, note.createdAt, note.updatedAt, note.synced ? 1 : 0],
);
},
getUnsynced(): Note[] {
const rows = db.getAllSync<{
id: string;
title: string;
body: string;
created_at: number;
updated_at: number;
synced: number;
}>('SELECT * FROM notes WHERE synced = 0');
return rows.map((row) => ({
id: row.id,
title: row.title,
body: row.body,
createdAt: row.created_at,
updatedAt: row.updated_at,
synced: false,
}));
},
};import * as Keychain from 'react-native-keychain';
const SERVICE_NAME = 'com.myapp.auth';
interface AuthTokens {
accessToken: string;
refreshToken: string;
}
export const secureStorage = {
async saveTokens(tokens: AuthTokens): Promise<void> {
await Keychain.setGenericPassword(
'auth',
JSON.stringify(tokens),
{
service: SERVICE_NAME,
accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
},
);
},
async getTokens(): Promise<AuthTokens | null> {
const credentials = await Keychain.getGenericPassword({
service: SERVICE_NAME,
});
if (!credentials) return null;
return JSON.parse(credentials.password) as AuthTokens;
},
async clearTokens(): Promise<void> {
await Keychain.resetGenericPassword({ service: SERVICE_NAME });
},
};import NetInfo from '@react-native-community/netinfo';
import { mmkv } from './mmkvStorage';
import { apiClient } from './apiClient';
interface SyncOperation {
id: string;
type: 'CREATE' | 'UPDATE' | 'DELETE';
entity: string;
payload: Record<string, unknown>;
timestamp: number;
retries: number;
}
const QUEUE_KEY = 'sync_queue';
class SyncEngine {
private isProcessing = false;
enqueue(operation: Omit<SyncOperation, 'id' | 'timestamp' | 'retries'>): void {
const queue = this.getQueue();
queue.push({
...operation,
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
timestamp: Date.now(),
retries: 0,
});
mmkv.set(QUEUE_KEY, JSON.stringify(queue));
this.processQueue();
}
private getQueue(): SyncOperation[] {
const raw = mmkv.getString(QUEUE_KEY);
return raw ? (JSON.parse(raw) as SyncOperation[]) : [];
}
async processQueue(): Promise<void> {
if (this.isProcessing) return;
const netState = await NetInfo.fetch();
if (!netState.isConnected) return;
this.isProcessing = true;
const queue = this.getQueue();
const remaining: SyncOperation[] = [];
for (const op of queue) {
try {
await this.executeOperation(op);
} catch (error) {
if (op.retries < 3) {
remaining.push({ ...op, retries: op.retries + 1 });
}
}
}
mmkv.set(QUEUE_KEY, JSON.stringify(remaining));
this.isProcessing = false;
}
private async executeOperation(op: SyncOperation): Promise<void> {
switch (op.type) {
case 'CREATE':
await apiClient.post(`/${op.entity}`, op.payload);
break;
case 'UPDATE':
await apiClient.put(`/${op.entity}/${op.payload.id}`, op.payload);
break;
case 'DELETE':
await apiClient.delete(`/${op.entity}/${op.payload.id}`);
break;
}
}
}
export const syncEngine = new SyncEngine();
NetInfo.addEventListener((state) => {
if (state.isConnected) {
syncEngine.processQueue();
}
});Answer:
| Criteria | AsyncStorage | MMKV | SQLite |
|---|---|---|---|
| Data size | < 1MB | < 10MB | Unlimited |
| Data structure | Simple key-value | Simple key-value | Relational with queries |
| Read performance | Slow (async bridge) | Very fast (sync, mmap) | Good with indexes |
| Encryption | No | Yes (built-in) | Via SQLCipher extension |
| Querying | Key lookup only | Key lookup only | Full SQL |
| Complexity | Minimal | Minimal | Moderate (migrations, schema) |
Decision tree:
- User preferences, feature flags, onboarding state → MMKV (or AsyncStorage for simplicity)
- Auth tokens, credentials → Keychain/Keystore (never AsyncStorage)
- Cached API responses, structured lists → SQLite
- Complex objects with relationships, live queries → Realm
- Offline-first with sync → SQLite or Realm with sync engine
Interview tip: Mention that AsyncStorage is being replaced by MMKV in most production apps due to performance. AsyncStorage operations can take 50-200ms per call due to bridge serialization.
Answer:
-
Performance — Every read/write crosses the JS-native bridge asynchronously. Batch operations with
multiGet/multiSethelp but are still slow. -
No encryption — Data stored in plaintext. Accessible on rooted/jailbroken devices.
-
String-only — Must JSON.stringify/parse everything manually.
-
Size limits — No hard limit, but performance degrades significantly above ~6MB on Android.
-
No querying — Can only retrieve by exact key. No search, filter, or sort.
-
No transactions — Race conditions possible with concurrent writes.
-
No schema — No validation or migration support.
-
Main thread blocking on large reads — JSON.parse of large strings blocks JS thread.
// Problematic — storing large datasets
await AsyncStorage.setItem('products', JSON.stringify(tenThousandProducts));
// Better — use SQLite or MMKV for large/fast dataFor new projects, default to MMKV for key-value and SQLite for structured data. Reserve AsyncStorage for legacy compatibility or extremely simple apps.
Answer:
MMKV (Memory-Mapped Key-Value) uses fundamentally different storage architecture:
AsyncStorage:
- JS call → Bridge serialization → Native module → File I/O → Bridge deserialization → JS callback
- Fully asynchronous — every operation is a round trip
- Stores data as files managed by SQLite (Android) or files (iOS)
MMKV:
- Memory-mapped files — File mapped directly into process memory space
- Synchronous API — No bridge round trip for reads/writes
- Protobuf encoding — Compact binary serialization
- Multi-process safe — Lock-free concurrent access
- Incremental sync — Only changed data written to disk
Performance benchmarks (typical):
- MMKV read: ~0.1ms (sync)
- AsyncStorage read: ~3-50ms (async, bridge overhead)
- MMKV is 10-30x faster for typical operations
// MMKV — synchronous, no await needed
const value = mmkv.getString('key');
mmkv.set('key', 'value');
// AsyncStorage — always async
const value = await AsyncStorage.getItem('key');
await AsyncStorage.setItem('key', 'value');Trade-off: MMKV is synchronous, so extremely large reads could block the JS thread. For typical key-value sizes (< 1MB), this is negligible.
Answer:
Use a version-tracked migration system:
const CURRENT_VERSION = 3;
const migrations: Record<number, string[]> = {
1: [
`CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
name TEXT NOT NULL
)`,
],
2: [
`ALTER TABLE users ADD COLUMN email TEXT`,
],
3: [
`CREATE TABLE IF NOT EXISTS posts (
id TEXT PRIMARY KEY,
user_id TEXT REFERENCES users(id),
title TEXT NOT NULL,
body TEXT
)`,
`CREATE INDEX idx_posts_user ON posts(user_id)`,
],
};
async function migrate(db: SQLiteDatabase): Promise<void> {
db.execSync(`
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER NOT NULL
)
`);
const result = db.getFirstSync<{ version: number }>(
'SELECT version FROM schema_version LIMIT 1',
);
let currentVersion = result?.version ?? 0;
db.withTransactionSync(() => {
while (currentVersion < CURRENT_VERSION) {
currentVersion++;
const scripts = migrations[currentVersion];
if (scripts) {
for (const sql of scripts) {
db.execSync(sql);
}
}
db.runSync(
'INSERT OR REPLACE INTO schema_version (version) VALUES (?)',
[currentVersion],
);
}
});
}Best practices:
- Never modify existing migrations — always add new version
- Wrap migrations in transactions
- Test migrations with production-size data
- Handle
ALTER TABLElimitations on SQLite (can't drop columns in older versions — use table recreation pattern) - Consider libraries:
drizzle-orm,typeorm, orexpo-sqlitewith custom migration runner
Answer:
Never use AsyncStorage or MMKV (unencrypted) for:
- Auth tokens (access/refresh)
- Passwords or PINs
- API secrets
- PII (personally identifiable information)
- Encryption keys
Use platform secure storage:
import * as Keychain from 'react-native-keychain';
// iOS: Keychain Services (hardware encryption on Secure Enclave devices)
// Android: EncryptedSharedPreferences + Keystore
await Keychain.setGenericPassword('username', 'token', {
service: 'com.app.auth',
accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
securityLevel: Keychain.SECURITY_LEVEL.SECURE_HARDWARE, // Android
});Security layers for defense in depth:
- Secure storage for tokens (Keychain/Keystore)
- Certificate pinning for API communication
- Biometric authentication before accessing sensitive data
- Root/jailbreak detection for high-security apps
- Auto-clear tokens on background after timeout
- No logging of sensitive values in production
Expo alternative: expo-secure-store provides similar Keychain/Keystore access.
Interview follow-up: Keychain data persists across app updates but is removed on uninstall (iOS). Android Keystore keys can be invalidated on biometric enrollment changes.
Answer:
Realm is a mobile-first object database that stores data as native objects rather than rows:
| Feature | Realm | SQLite |
|---|---|---|
| Data model | Objects with schema | Tables with SQL |
| Queries | Realm Query Language | SQL |
| Relationships | Object references (lazy) | Foreign keys + JOINs |
| Live updates | Built-in (Observable) | Manual (polling/triggers) |
| Sync | MongoDB Atlas Device Sync | Custom sync layer |
| Learning curve | Medium (proprietary API) | Low (standard SQL) |
| Migration | Schema versioning built-in | Manual SQL migrations |
Choose Realm when:
- Complex object graphs with relationships
- Need live, reactive data updates in UI
- Using MongoDB Atlas for backend sync
- Team prefers object-oriented data access
Choose SQLite when:
- Team knows SQL well
- Need complex queries (JOINs, aggregations, window functions)
- Want ORM flexibility (Drizzle, TypeORM)
- Open-source, no vendor dependency
- Existing backend with REST/GraphQL (no Realm sync needed)
// Realm — object-oriented
const tasks = realm.objects('Task').filtered('isComplete == false');
// SQLite — relational
const tasks = db.getAllSync(
'SELECT * FROM tasks WHERE is_complete = 0 ORDER BY created_at DESC',
);Note: Realm SDK has evolved — check current MongoDB Realm / Atlas Device Sync status for your project's needs.
Answer:
Offline-first treats local storage as the source of truth with background sync:
Architecture layers:
UI → Local Database (SQLite/Realm) → Sync Engine → Remote API
↑ read/write here ↑ background sync
Implementation steps:
- Write locally first — All mutations go to local DB immediately
- Mark as unsynced —
synced: falseflag on modified records - Queue mutations — Store operation type, payload, timestamp
- Background sync — Process queue when NetInfo reports connectivity
- Conflict resolution — Define strategy (last-write-wins, server wins, manual merge)
- UI reads local data — Never block UI on network
// User creates a note — instant UI update
async function createNote(title: string, body: string) {
const note = { id: uuid(), title, body, synced: false, updatedAt: Date.now() };
noteRepository.insert(note); // Local first
syncEngine.enqueue({ // Queue for sync
type: 'CREATE',
entity: 'notes',
payload: note,
});
return note;
}With TanStack Query (simpler approach):
networkMode: 'offlineFirst'— Use cache when offlinepersistQueryClient— Persist cache to storage- Optimistic mutations for instant UI
Conflict resolution strategies:
- Last-write-wins — Compare
updatedAttimestamps - Server-authoritative — Server version always wins
- CRDTs — Conflict-free replicated data types (advanced)
- Manual merge — Present conflicts to user
Answer:
Both support persist middleware that serializes state to storage:
Zustand with MMKV:
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { mmkvStorage } from './mmkvStorage';
interface AppState {
theme: 'light' | 'dark';
setTheme: (theme: 'light' | 'dark') => void;
}
export const useAppStore = create<AppState>()(
persist(
(set) => ({
theme: 'light',
setTheme: (theme) => set({ theme }),
}),
{
name: 'app-storage',
storage: createJSONStorage(() => mmkvStorage),
partialize: (state) => ({ theme: state.theme }),
},
),
);Redux Toolkit with redux-persist:
import { configureStore } from '@reduxjs/toolkit';
import { persistStore, persistReducer } from 'redux-persist';
import AsyncStorage from '@react-native-async-storage/async-storage';
const persistConfig = {
key: 'root',
storage: AsyncStorage,
whitelist: ['auth', 'preferences'],
blacklist: ['temporaryFormData'],
};
const persistedReducer = persistReducer(persistConfig, rootReducer);Key considerations:
- partialize/whitelist — Only persist necessary state (not loading flags, errors)
- Migration — Handle state shape changes between app versions
- Rehydration timing — Show splash/loading until persist rehydrates
- Secure data — Don't persist tokens in Zustand/Redux persist; use Keychain separately
- Performance — Use MMKV adapter instead of AsyncStorage for faster rehydration
| Practice | Reason |
|---|---|
| Use MMKV over AsyncStorage for key-value | 10-30x performance improvement |
| Use Keychain/Keystore for tokens | Encrypted, hardware-backed storage |
| Use SQLite for structured/queryable data | Proper indexing and relationships |
| Implement database migrations from day one | Schema evolves; migrations prevent data loss |
| Write locally first in offline-first apps | Instant UI, sync in background |
| Partialize persisted state | Don't persist transient UI state |
| Batch AsyncStorage operations | multiGet/multiSet reduce bridge calls |
| Test on low-end devices with large datasets | Storage performance varies by device |
Navigation: ← Push Notifications | Native Modules & Bridge →