Offline Storage
Offline storage strategies in React Native using AsyncStorage, MMKV, and WatermelonDB
You are an expert in offline storage for building cross-platform mobile apps with React Native.
## Key Points
- Use MMKV instead of AsyncStorage for any performance-sensitive storage. MMKV is synchronous and 30x faster than AsyncStorage.
- For simple key-value data (tokens, preferences, small caches), MMKV is the best choice. Use WatermelonDB only when you need relational queries, indexes, or large datasets (1000+ records).
- Enable MMKV encryption for sensitive data (tokens, PII) with `new MMKV({ encryptionKey: "..." })`.
- Always index columns used in `Q.where()` clauses in WatermelonDB schemas.
- Use WatermelonDB's built-in `synchronize` for offline sync rather than building a custom solution.
- Implement schema migrations in WatermelonDB from day one — adding them retroactively to production apps is painful.
- **AsyncStorage 6MB limit on Android**: Android's default AsyncStorage backend has a ~6MB limit. Large datasets silently fail. Use MMKV or SQLite for anything substantial.
- **Storing large blobs in key-value stores**: Neither AsyncStorage nor MMKV is designed for large binary data. Use the filesystem (`expo-file-system`) for images, audio, and video.
- **Not handling storage errors**: MMKV can throw if the device is out of disk space. Wrap write operations in try/catch for production resilience.
- **WatermelonDB forgetting `@writer`**: All mutation operations must use the `@writer` decorator or `database.write()`. Direct mutations outside a writer will throw.
- **Stale cache without invalidation**: Cached API responses need TTL checks or version stamps. Serving indefinitely stale data degrades user experience worse than showing an error.
- **Not clearing storage on logout**: Sensitive user data persisted in MMKV or WatermelonDB must be cleared when the user logs out. Use separate MMKV instances per user or call `clearAll()`.
## Quick Example
```bash
npx expo install @react-native-async-storage/async-storage
```
```bash
npx expo install react-native-mmkv
```skilldb get react-native-skills/Offline StorageFull skill: 422 linesOffline Storage — React Native
You are an expert in offline storage for building cross-platform mobile apps with React Native.
Core Philosophy
Mobile apps must work when the network does not. Users open apps on subways, in elevators, and in areas with spotty coverage. Offline storage is not a feature -- it is a baseline expectation. The question is not whether to store data locally but which storage solution matches your data's shape, volume, and access patterns. Key-value stores (MMKV, AsyncStorage) are right for configuration and small caches. Relational databases (WatermelonDB, expo-sqlite) are right for large datasets with complex queries. Using the wrong tool creates performance problems that are difficult to fix later.
MMKV should be the default key-value store for React Native projects. It is synchronous (no await needed), 30x faster than AsyncStorage, supports encryption, and can handle hundreds of megabytes of data. AsyncStorage's 6MB limit on Android, its asynchronous API, and its slower performance make it suitable only for projects that cannot add native dependencies. For any greenfield project, MMKV is the better choice for settings, tokens, caches, and state persistence.
Offline-first architecture means the local database is the source of truth for the UI, not the network. The UI reads from the local store, displays immediately, and background sync keeps the local store updated from the server. When the network is unavailable, the app continues to work with cached data. When connectivity returns, pending changes are pushed to the server and fresh data is pulled down. This pattern requires careful conflict resolution and change tracking, which is why WatermelonDB's built-in synchronize function exists -- building a custom sync engine is harder than it looks.
Anti-Patterns
-
Using AsyncStorage for performance-sensitive operations: AsyncStorage is asynchronous, has a 6MB limit on Android, and is measurably slower than alternatives. Using it for state persistence, frequent reads/writes, or caching API responses creates unnecessary latency. MMKV handles these use cases with synchronous access and better performance.
-
Storing large binary data in key-value stores: Neither MMKV nor AsyncStorage is designed for images, audio files, or video. Binary data should be stored on the filesystem using
expo-file-systemorreact-native-fs, with only the file path stored in the key-value store. -
Serving indefinitely stale cached data without invalidation: Cached API responses need TTL checks, version stamps, or explicit invalidation. Showing data from three months ago without any indication that it may be outdated is worse than showing an error, because the user trusts stale data and makes decisions based on it.
-
Not clearing user-specific storage on logout: Tokens, personal data, and cached content stored in MMKV or WatermelonDB must be cleared when the user logs out. If a different user logs into the same device, they see the previous user's data. Use separate storage instances per user or call
clearAll()on logout. -
Building a custom sync engine instead of using WatermelonDB's synchronize: Sync is deceptively complex: conflict resolution, partial failures, schema migrations during sync, and idempotent push/pull all need to be handled correctly. WatermelonDB's
synchronizefunction handles these cases and has been battle-tested in production apps.
Overview
Mobile apps must handle intermittent connectivity gracefully. React Native offers several storage solutions at different complexity levels: AsyncStorage for simple key-value pairs, MMKV for high-performance synchronous storage, and WatermelonDB for relational offline-first databases. Choosing the right tool depends on data volume, query complexity, and performance requirements.
Core Concepts
Storage Solution Comparison
| Solution | Type | Sync/Async | Max Data | Best For |
|---|---|---|---|---|
| AsyncStorage | Key-value | Async | ~6MB (Android) | Small config, tokens |
| MMKV | Key-value | Sync | Hundreds of MB | Settings, caches, state persistence |
| WatermelonDB | Relational (SQLite) | Async (lazy) | GBs | Large datasets, offline sync |
| expo-sqlite | Relational | Sync + Async | GBs | Direct SQL, migrations |
AsyncStorage
npx expo install @react-native-async-storage/async-storage
import AsyncStorage from "@react-native-async-storage/async-storage";
// Store data
await AsyncStorage.setItem("user-token", token);
await AsyncStorage.setItem("user-prefs", JSON.stringify({ theme: "dark" }));
// Retrieve data
const token = await AsyncStorage.getItem("user-token");
const prefs = JSON.parse((await AsyncStorage.getItem("user-prefs")) ?? "{}");
// Remove data
await AsyncStorage.removeItem("user-token");
// Multi operations (batch)
await AsyncStorage.multiSet([
["key1", "value1"],
["key2", "value2"],
]);
const results = await AsyncStorage.multiGet(["key1", "key2"]);
// results = [["key1", "value1"], ["key2", "value2"]]
MMKV
npx expo install react-native-mmkv
import { MMKV } from "react-native-mmkv";
// Create an instance (can have multiple isolated stores)
const storage = new MMKV();
const secureStorage = new MMKV({ id: "secure", encryptionKey: "my-secret-key" });
// Synchronous operations — no await needed
storage.set("username", "alice");
storage.set("login-count", 42);
storage.set("premium-user", true);
const username = storage.getString("username"); // "alice"
const count = storage.getNumber("login-count"); // 42
const isPremium = storage.getBoolean("premium-user"); // true
// Check existence
if (storage.contains("username")) {
// ...
}
// Delete
storage.delete("username");
// Get all keys
const allKeys = storage.getAllKeys(); // ["login-count", "premium-user"]
// Clear everything
storage.clearAll();
MMKV with JSON
// Helper for typed JSON storage
class TypedStorage {
private mmkv: MMKV;
constructor(id?: string) {
this.mmkv = new MMKV({ id });
}
get<T>(key: string): T | null {
const value = this.mmkv.getString(key);
if (value === undefined) return null;
try {
return JSON.parse(value) as T;
} catch {
return null;
}
}
set<T>(key: string, value: T): void {
this.mmkv.set(key, JSON.stringify(value));
}
delete(key: string): void {
this.mmkv.delete(key);
}
}
const appStorage = new TypedStorage("app");
interface UserProfile {
id: string;
name: string;
avatar: string;
}
appStorage.set<UserProfile>("profile", { id: "1", name: "Alice", avatar: "..." });
const profile = appStorage.get<UserProfile>("profile");
MMKV as React State Hook
import { useMMKVString, useMMKVBoolean, useMMKVNumber } from "react-native-mmkv";
function SettingsScreen() {
const [theme, setTheme] = useMMKVString("app.theme");
const [notifications, setNotifications] = useMMKVBoolean("app.notifications");
const [fontSize, setFontSize] = useMMKVNumber("app.fontSize");
return (
<View>
<Switch
value={notifications}
onValueChange={setNotifications} // Persisted automatically
/>
<Slider
value={fontSize ?? 16}
onValueChange={setFontSize}
minimumValue={12}
maximumValue={24}
/>
</View>
);
}
Implementation Patterns
WatermelonDB Setup
npm install @nozbe/watermelondb
npx expo install @nozbe/watermelondb
// database/schema.ts
import { appSchema, tableSchema } from "@nozbe/watermelondb";
export const schema = appSchema({
version: 1,
tables: [
tableSchema({
name: "posts",
columns: [
{ name: "title", type: "string" },
{ name: "body", type: "string" },
{ name: "is_published", type: "boolean" },
{ name: "author_id", type: "string", isIndexed: true },
{ name: "created_at", type: "number" },
{ name: "updated_at", type: "number" },
],
}),
tableSchema({
name: "comments",
columns: [
{ name: "body", type: "string" },
{ name: "post_id", type: "string", isIndexed: true },
{ name: "author_id", type: "string", isIndexed: true },
{ name: "created_at", type: "number" },
],
}),
],
});
// database/models/Post.ts
import { Model, Q } from "@nozbe/watermelondb";
import { field, text, date, children, writer } from "@nozbe/watermelondb/decorators";
export class Post extends Model {
static table = "posts";
static associations = {
comments: { type: "has_many" as const, foreignKey: "post_id" },
};
@text("title") title!: string;
@text("body") body!: string;
@field("is_published") isPublished!: boolean;
@text("author_id") authorId!: string;
@date("created_at") createdAt!: Date;
@date("updated_at") updatedAt!: Date;
@children("comments") comments!: any;
@writer async publish() {
await this.update((post) => {
post.isPublished = true;
});
}
@writer async addComment(body: string, authorId: string) {
return this.collections.get("comments").create((comment: any) => {
comment.post.set(this);
comment.body = body;
comment.authorId = authorId;
});
}
}
// database/index.ts
import { Database } from "@nozbe/watermelondb";
import SQLiteAdapter from "@nozbe/watermelondb/adapters/sqlite";
import { schema } from "./schema";
import { Post } from "./models/Post";
import { Comment } from "./models/Comment";
const adapter = new SQLiteAdapter({
schema,
jsi: true, // Enable JSI for better performance
onSetUpError: (error) => {
console.error("Database setup failed:", error);
},
});
export const database = new Database({
adapter,
modelClasses: [Post, Comment],
});
WatermelonDB Queries with React
import { withObservables } from "@nozbe/watermelondb/react";
import { Q } from "@nozbe/watermelondb";
import { database } from "../database";
// Enhanced component that observes database changes
const PostList = withObservables([], () => ({
posts: database
.get<Post>("posts")
.query(Q.where("is_published", true), Q.sortBy("created_at", "desc"))
.observe(),
}))(({ posts }: { posts: Post[] }) => (
<FlatList
data={posts}
renderItem={({ item }) => <PostRow post={item} />}
keyExtractor={(item) => item.id}
/>
));
// Using the hook API (WatermelonDB v0.27+)
import { useQuery } from "@nozbe/watermelondb/react";
function PostListScreen() {
const posts = useQuery(
database.get<Post>("posts"),
Q.where("is_published", true),
Q.sortBy("created_at", "desc")
);
return (
<FlatList
data={posts}
renderItem={({ item }) => <PostRow post={item} />}
/>
);
}
Offline Sync Pattern
// sync/sync.ts
import { synchronize } from "@nozbe/watermelondb/sync";
import { database } from "../database";
export async function syncDatabase() {
await synchronize({
database,
pullChanges: async ({ lastPulledAt }) => {
const response = await fetch(
`https://api.example.com/sync?last_pulled_at=${lastPulledAt ?? 0}`
);
if (!response.ok) throw new Error("Sync pull failed");
const { changes, timestamp } = await response.json();
return { changes, timestamp };
},
pushChanges: async ({ changes, lastPulledAt }) => {
const response = await fetch("https://api.example.com/sync", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ changes, lastPulledAt }),
});
if (!response.ok) throw new Error("Sync push failed");
},
migrationsEnabledAtVersion: 1,
});
}
// Trigger sync on app foreground
import { AppState } from "react-native";
function useSyncOnForeground() {
useEffect(() => {
const subscription = AppState.addEventListener("change", (state) => {
if (state === "active") {
syncDatabase().catch(console.error);
}
});
return () => subscription.remove();
}, []);
}
Network-Aware Storage Layer
import NetInfo from "@react-native-community/netinfo";
class OfflineFirstApi {
private cache: MMKV;
constructor() {
this.cache = new MMKV({ id: "api-cache" });
}
async fetch<T>(url: string, options?: RequestInit): Promise<T> {
const cacheKey = `cache:${url}`;
const netState = await NetInfo.fetch();
if (netState.isConnected) {
try {
const response = await fetch(url, options);
const data = await response.json();
this.cache.set(cacheKey, JSON.stringify({
data,
timestamp: Date.now(),
}));
return data as T;
} catch {
return this.getCached<T>(cacheKey);
}
}
return this.getCached<T>(cacheKey);
}
private getCached<T>(key: string): T {
const cached = this.cache.getString(key);
if (!cached) throw new Error("No cached data available");
return JSON.parse(cached).data as T;
}
}
Best Practices
- Use MMKV instead of AsyncStorage for any performance-sensitive storage. MMKV is synchronous and 30x faster than AsyncStorage.
- For simple key-value data (tokens, preferences, small caches), MMKV is the best choice. Use WatermelonDB only when you need relational queries, indexes, or large datasets (1000+ records).
- Enable MMKV encryption for sensitive data (tokens, PII) with
new MMKV({ encryptionKey: "..." }). - Always index columns used in
Q.where()clauses in WatermelonDB schemas. - Use WatermelonDB's built-in
synchronizefor offline sync rather than building a custom solution. - Implement schema migrations in WatermelonDB from day one — adding them retroactively to production apps is painful.
Common Pitfalls
- AsyncStorage 6MB limit on Android: Android's default AsyncStorage backend has a ~6MB limit. Large datasets silently fail. Use MMKV or SQLite for anything substantial.
- Storing large blobs in key-value stores: Neither AsyncStorage nor MMKV is designed for large binary data. Use the filesystem (
expo-file-system) for images, audio, and video. - Not handling storage errors: MMKV can throw if the device is out of disk space. Wrap write operations in try/catch for production resilience.
- WatermelonDB forgetting
@writer: All mutation operations must use the@writerdecorator ordatabase.write(). Direct mutations outside a writer will throw. - Stale cache without invalidation: Cached API responses need TTL checks or version stamps. Serving indefinitely stale data degrades user experience worse than showing an error.
- Not clearing storage on logout: Sensitive user data persisted in MMKV or WatermelonDB must be cleared when the user logs out. Use separate MMKV instances per user or call
clearAll().
Install this skill directly: skilldb add react-native-skills
Related Skills
Reanimated Animations
High-performance animations in React Native using Reanimated and Gesture Handler
Eas Build Ota Updates
Deploying React Native apps with EAS Build, app store submission, and OTA updates via EAS Update
Expo Managed Workflow
Expo managed workflow for rapid React Native development with minimal native configuration
Native Modules Turbo Modules
Creating native modules and Turbo Modules to bridge platform-specific functionality into React Native
React Navigation Patterns
React Navigation patterns for stack, tab, drawer, and nested navigators in React Native
State Management Zustand Jotai
State management in React Native using Zustand and Jotai for scalable, performant app state