DEV Community

Mason Roy
Mason Roy

Posted on

The Complete Guide to Offline Storage in React Native

Offline storage in React Native is the practice of persisting data on the device so your app keeps working when the network does not. The main options are AsyncStorage, MMKV, Expo SecureStore, SQLite, WatermelonDB, and Realm. Each solves a different problem: tiny preferences, hot key-value reads, secrets, structured relational data, or sync-ready local databases. Choosing well is less about picking the "fastest" library and more about matching the storage engine to your access pattern — then wrapping it in an offline-first architecture that survives flaky networks.

If you have ever watched a user curse at a loading spinner on the subway, you already understand why this matters. In 2026 users expect mobile apps to launch instantly, show real data, and accept writes whether or not the radio is connected. This guide walks through every serious offline storage option in React Native, the decision framework for picking one, and the architecture that turns a storage choice into a true offline-first experience.

Developer working on a React Native mobile app
Photo by Christopher Gower on Unsplash

Why Offline Storage Is Non-Negotiable in 2026

Three forces have pushed offline support from a nice-to-have to a baseline expectation.

Networks are still flaky. Subway tunnels, airplane cabins, elevators, rural areas, dense stadiums — users spend measurable chunks of time on degraded or absent connectivity. If your app stalls there, they churn.

Cold start is the new conversion funnel. Apps that hydrate from local storage show real content in under 300ms. Apps that fetch from the network first show skeletons for 1-3 seconds and lose attention. Instagram, Notion, and Linear all render from cache first, then reconcile.

App Store and Play Store reviewers notice. Both stores have rejected apps that show a blank screen when offline. A single <Text>No internet</Text> fallback is enough to pass, but if you are competing, the bar is higher.

Offline storage is the foundation that makes all three possible.

The Six Offline Storage Options in React Native

Before architecture, you need to know what each tool is actually good at. Here is the honest 2026 landscape.

1. AsyncStorage

The default. A simple asynchronous key-value store, backed by SQLite on iOS and SharedPreferences/SQLite on Android. Good for small, serializable values. The API is dead simple:

import AsyncStorage from '@react-native-async-storage/async-storage';

await AsyncStorage.setItem('user_prefs', JSON.stringify(prefs));
const raw = await AsyncStorage.getItem('user_prefs');
Enter fullscreen mode Exit fullscreen mode

Use when: You have fewer than ~100 small values, each read on-demand (not in a tight render loop), and you do not need encryption. Fine for prototypes and small preference blobs.

Avoid when: You are serializing arrays of records, reading in render, or storing anything sensitive. The async overhead and JSON serialization costs add up fast.

2. MMKV (react-native-mmkv)

A key-value store built on Tencent's MMKV library, exposed through JSI (synchronous bridge). It is often 30-100x faster than AsyncStorage for small reads, with sub-millisecond operations because there is no serialization bridge hop. It also supports encryption out of the box.

import { MMKV } from 'react-native-mmkv';

const storage = new MMKV({ id: 'user', encryptionKey: 'hunter2' });

storage.set('onboarded', true);
const onboarded = storage.getBoolean('onboarded'); // synchronous
Enter fullscreen mode Exit fullscreen mode

Use when: You need fast, frequent key-value reads — auth tokens, feature flags, user preferences, small JSON blobs. The synchronous API means you can read a token inside a selector without a Promise chain.

Avoid when: You need relational queries, large records, or complex schemas. MMKV is a hashmap, not a database.

3. Expo SecureStore

Thin wrapper over iOS Keychain and Android Keystore. Values are encrypted at the OS level and tied to device credentials. Capacity is limited (iOS allows ~2KB per item in practice), but security is strong.

Use when: You store secrets — OAuth refresh tokens, API keys, biometric-protected credentials. Anything that must survive an app reinstall on iOS (Keychain persists by default) or must be hardware-isolated.

Avoid when: You need to store more than a handful of small secrets. SecureStore is slow by design (it crosses the OS trust boundary) and not meant for bulk data.

4. SQLite (expo-sqlite / op-sqlite)

A full relational database on the device. expo-sqlite ships with Expo and now supports synchronous JSI access via openDatabaseSync. op-sqlite is a drop-in with even better performance and native encryption support (SQLCipher).

import * as SQLite from 'expo-sqlite';

const db = SQLite.openDatabaseSync('app.db');
db.execSync('CREATE TABLE IF NOT EXISTS notes (id TEXT PRIMARY KEY, body TEXT, updated_at INTEGER)');

const notes = db.getAllSync('SELECT * FROM notes WHERE updated_at > ?', [since]);
Enter fullscreen mode Exit fullscreen mode

Use when: You have structured, queryable data — notes, messages, orders, products. Anything with relationships, filters, or indexes. SQLite scales to hundreds of thousands of rows on a modern phone.

Avoid when: You need reactive queries (your UI auto-updates when the DB changes) out of the box. Plain SQLite requires you to roll your own invalidation.

5. WatermelonDB

An opinionated, high-performance ORM on top of SQLite with reactive queries, lazy loading, and a batteries-included sync protocol. Designed for apps with tens of thousands of records.

Use when: You are building offline-first apps with large local datasets and bidirectional sync — think CRM, project management, or messaging. The reactive layer eliminates manual cache invalidation and the sync adapter handles pull/push cycles for you.

Avoid when: Your dataset is small, or you want full control over the SQL layer. WatermelonDB's abstractions have a learning curve.

6. Realm (Atlas Device SDK)

A mobile-first object database with its own storage engine (not SQLite-backed). Reactive queries, strong typing, and historically a slick sync product via MongoDB Atlas. MongoDB has deprioritized the hosted sync service, but the local Realm database is still a solid, stable choice.

Use when: You want reactive queries with an object-oriented API and are not depending on the hosted Atlas Device Sync going forward.

Avoid when: You want long-term vendor-managed sync — look at PowerSync or roll your own on top of SQLite.

Mobile phone displaying a dashboard with offline data
Photo by Carlos Muza on Unsplash

Choosing the Right Storage: A Decision Framework

The honest answer is almost always "more than one." Real apps combine two or three storage engines because each solves a different job.

Need Pick Why
Auth tokens, secrets Expo SecureStore OS-level encryption, Keychain/Keystore
User preferences, feature flags MMKV Sub-ms sync reads, tiny footprint
Small, read-once JSON (migration flag) AsyncStorage or MMKV Either works; MMKV future-proofs
Structured records with queries SQLite (expo-sqlite) SQL, indexes, joins
Large offline-first dataset with sync WatermelonDB Reactive queries + sync adapter
Reactive object DB without sync Realm Clean API, zero-copy reads

A typical production stack looks like this: SecureStore for the refresh token, MMKV for session state and user preferences, and SQLite (or WatermelonDB on top of it) for domain data. That is three storage engines, and they do not fight — each owns a different concern.

Building a Real Offline-First Architecture

Picking a storage library gets you maybe 20% of the way. The other 80% is the architecture that makes writes survive airplane mode and reads survive cold starts. Four pieces matter.

1. Detect connectivity with NetInfo

@react-native-community/netinfo exposes real connectivity state (not just the radio status — it does a reachability check). Subscribe once and expose a isConnected flag through context or a store.

import NetInfo from '@react-native-community/netinfo';

NetInfo.addEventListener((state) => {
  store.dispatch(setConnected(state.isInternetReachable));
});
Enter fullscreen mode Exit fullscreen mode

Do not trust state.isConnected alone — on Wi-Fi captive portals the radio is "connected" but the internet is not reachable. Use isInternetReachable.

2. Hydrate the UI from local storage first

On app start, read from local storage synchronously (MMKV) or with a tiny Promise (SQLite JSI) and render immediately. Then fire a background refresh. Your users should never see a blank screen because of a network request.

const cached = storage.getString('home_feed');
if (cached) hydrateFeed(JSON.parse(cached));
void refreshFeedInBackground();
Enter fullscreen mode Exit fullscreen mode

3. Queue writes while offline

When a user creates, edits, or deletes something offline, store two things: the authoritative local row (so the UI reflects the change immediately) and a mutation entry in a sync queue. The queue is a plain SQLite table with columns like id, type, payload, created_at, attempts, last_error.

CREATE TABLE mutation_queue (
  id TEXT PRIMARY KEY,
  type TEXT NOT NULL,
  payload TEXT NOT NULL,
  created_at INTEGER NOT NULL,
  attempts INTEGER DEFAULT 0,
  last_error TEXT
);
Enter fullscreen mode Exit fullscreen mode

When connectivity returns (via the NetInfo listener), drain the queue in order. Mark each mutation idempotent — include a client-generated UUID so the server can deduplicate retries. Without idempotency keys, a flaky connection creates duplicates every time a request succeeds server-side but the response never reaches the client.

4. Resolve conflicts explicitly

Two devices edit the same note offline. Both come back online. You now have a conflict. Three strategies, in order of complexity:

  • Last-write-wins (LWW): Compare timestamps, newer wins. Simple, lossy. Fine for user preferences and non-collaborative data.
  • Server-authoritative merge: Server applies both mutations in order and returns the canonical state. You replace local state on sync. Good for simple domains like a shopping cart.
  • CRDTs or operational transforms: Required for collaborative editing (think Notion, Linear). Libraries like Yjs or Automerge handle this; they are heavy but produce correct merges without a central authority.

Pick the simplest one that matches your product. 90% of apps are fine with LWW plus a visible "last synced at" timestamp.

A Minimal Offline-First Setup, in Code

Here is a realistic skeleton combining MMKV, expo-sqlite, and a NetInfo-driven sync queue. It is intentionally small so the pattern is visible.

// storage.ts
import { MMKV } from 'react-native-mmkv';
import * as SQLite from 'expo-sqlite';

export const kv = new MMKV({ id: 'app' });
export const db = SQLite.openDatabaseSync('app.db');

db.execSync(`
  CREATE TABLE IF NOT EXISTS notes (
    id TEXT PRIMARY KEY,
    body TEXT,
    updated_at INTEGER
  );
  CREATE TABLE IF NOT EXISTS mutation_queue (
    id TEXT PRIMARY KEY,
    type TEXT,
    payload TEXT,
    created_at INTEGER,
    attempts INTEGER DEFAULT 0
  );
`);
Enter fullscreen mode Exit fullscreen mode
// notes.ts
import { db } from './storage';
import { randomUUID } from 'expo-crypto';

export function createNoteOffline(body: string) {
  const id = randomUUID();
  const now = Date.now();

  db.runSync('INSERT INTO notes (id, body, updated_at) VALUES (?, ?, ?)', [id, body, now]);
  db.runSync(
    'INSERT INTO mutation_queue (id, type, payload, created_at) VALUES (?, ?, ?, ?)',
    [randomUUID(), 'create_note', JSON.stringify({ id, body }), now]
  );

  return id;
}
Enter fullscreen mode Exit fullscreen mode
// sync.ts
import NetInfo from '@react-native-community/netinfo';
import { db } from './storage';

NetInfo.addEventListener(async (state) => {
  if (!state.isInternetReachable) return;

  const pending = db.getAllSync('SELECT * FROM mutation_queue ORDER BY created_at ASC');
  for (const m of pending) {
    try {
      await fetch(`/api/${m.type}`, {
        method: 'POST',
        headers: { 'Idempotency-Key': m.id },
        body: m.payload,
      });
      db.runSync('DELETE FROM mutation_queue WHERE id = ?', [m.id]);
    } catch (err) {
      db.runSync('UPDATE mutation_queue SET attempts = attempts + 1 WHERE id = ?', [m.id]);
      break; // stop on first failure, retry on next connectivity change
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

That is the entire core. Everything else — exponential backoff, mutation chunking, optimistic UI — is a refinement on top.

Common Pitfalls (and How to Dodge Them)

Serializing giant arrays into AsyncStorage. The classic performance trap. One developer stored their entire chat history as a single JSON string in AsyncStorage; the app froze for 2 seconds on launch. Move to SQLite the moment you have more than a few hundred records.

Storing secrets in MMKV without encryption. MMKV supports encryption but does not enable it by default. If you store refresh tokens, pass an encryptionKey — and store that key in SecureStore, not MMKV itself.

Trusting isConnected instead of isInternetReachable. Mentioned above but it bites so often it is worth repeating. Captive portals, misconfigured VPNs, and corporate networks all report "connected" while being unreachable.

No idempotency keys on retried mutations. Without them, a user who creates a post on a flaky network can end up with three identical posts. Generate the UUID on the client, send it on every retry, deduplicate on the server.

Blocking the JS thread with sync SQLite. JSI SQLite is synchronous, which is a feature — but a getAllSync over 50,000 rows will still block for hundreds of milliseconds. Use indexes, paginate, or drop to an async worker for heavy queries.

Code on a laptop screen showing a mobile app project
Photo by Markus Spiske on Unsplash

Performance: What the Numbers Actually Look Like

Benchmarks on a mid-range Android (Pixel 6a) in 2026, for storing a 1KB JSON blob:

Operation AsyncStorage MMKV expo-sqlite (JSI)
Single write 8-15 ms <1 ms 1-2 ms
Single read 6-12 ms <1 ms 1-2 ms
1000 sequential writes 4-6 s 60 ms 300 ms (in tx)
Read + parse large JSON (1 MB) 180-250 ms 90 ms 40 ms (indexed)

The pattern is consistent: MMKV wins on key-value speed, SQLite wins on structured bulk operations, AsyncStorage is the slowest at everything. If a user-visible interaction depends on a storage read, you want sub-millisecond latency — that rules out AsyncStorage for anything on a hot path.

How RapidNative Generates Offline-Ready React Native Code

When you describe an app to RapidNative in plain English, you get a working Expo project with real screens, real state, and — importantly — a sensible storage layer out of the box. The generator picks Expo SecureStore for tokens, MMKV for session preferences, and SQLite (via expo-sqlite) for the domain data your prompt described. You can see it in action by sketching an app or starting from a PRD.

What that means in practice: the generated project already has the storage engines wired, the schema modeled, and the hydration-on-mount pattern applied — the architecture described above, scaffolded automatically. You can export the code at any time and keep extending it (sync queues, encryption, custom conflict resolution) with any React Native patterns you prefer. For more on how the generated apps are structured, see our deep dives on state management in AI-generated React Native apps and database schema generation.

FAQ

Is AsyncStorage deprecated?

No. AsyncStorage is actively maintained by the React Native community and remains the default for simple preferences. It is just slow enough that most apps outgrow it. For new projects, start with MMKV and reach for AsyncStorage only when a dependency requires it.

Can I use multiple storage libraries in the same app?

Yes, and most production apps do. SecureStore for secrets, MMKV for preferences, and SQLite or WatermelonDB for domain data is the standard combination. They do not conflict — each owns a different concern.

How do I migrate from AsyncStorage to MMKV?

On first launch after the upgrade, read every AsyncStorage key, write it to MMKV, verify, then clear AsyncStorage. Gate the migration behind a flag stored in MMKV so it runs exactly once. Keep AsyncStorage as a dependency until you are confident the migration has reached your entire user base — usually one or two release cycles.

What is the best offline database for React Native in 2026?

For most apps, expo-sqlite with its JSI synchronous API is the best balance of performance, portability, and ecosystem support. If you need reactive queries and sync out of the box, WatermelonDB is excellent. Pick based on whether your data has strong relational structure and whether sync is a first-class concern.

Do I need an offline-first architecture if my app is always online?

Even "always online" apps face dropped requests, flaky Wi-Fi, and backgrounded sessions. At a minimum, cache the last response for every screen so cold start is instant. The full sync queue is overkill for a dashboard but mandatory for anything that accepts user writes.

Conclusion

Offline storage in React Native is a solved problem — you just have to pick the right tool for each job. MMKV for hot key-value reads, SQLite for structured data, SecureStore for secrets, and a sync queue to make writes resilient. Wrap it in a NetInfo-driven architecture that hydrates from cache first and reconciles with the server in the background, and your app will feel instant whether the network is there or not.

The hard part has never been the storage API. It is the discipline to design around flakiness from day one — because retrofitting offline support into an app that assumed always-online is one of the ugliest refactors in mobile development.

If you would rather skip the plumbing and get a Expo project with the storage layer already wired up, try RapidNative free — describe your app in plain words and get real, exportable React Native code in minutes.

Top comments (0)