DEV Community

Cover image for The Complete Guide to Offline Storage in React Native (2026)
Hugo Rus
Hugo Rus

Posted on

The Complete Guide to Offline Storage in React Native (2026)

Pick the wrong storage library in React Native and your app stalls on launch. Pick the right one and it feels instant on a subway with no signal. Here's the 2026 landscape: what each option is good at, when to combine them, and the architecture that makes writes survive airplane mode.

The six options, by job

AsyncStorage: the classic default. A simple async key-value store. Fine for a handful of small preferences; slow for anything large, because every call is asynchronous and values are stored as serialized strings.

MMKV: Tencent's key-value engine, exposed to JavaScript through JSI with synchronous reads. Its README claims it's about 30x faster than AsyncStorage, and it supports optional encryption. For a new project in 2026, it's the go-to key-value store.

import { createMMKV } from 'react-native-mmkv'; // v4 API

const storage = createMMKV({ id: 'prefs' });
storage.set('onboarded', true);
const onboarded = storage.getBoolean('onboarded'); // synchronous
Enter fullscreen mode Exit fullscreen mode

On MMKV v3, the equivalent is new MMKV({ id: 'prefs' }).

Expo SecureStore: a thin wrapper over the iOS Keychain and Android Keystore, encrypted by the OS. Slower than the others and meant for small values. Use it for refresh tokens and secrets only.

expo-sqlite: full relational SQLite with a synchronous API. Indexes, joins, transactions. Comfortably handles large datasets on a modern phone.

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 since = Date.now() - 24 * 60 * 60 * 1000;
const rows = db.getAllSync('SELECT * FROM notes WHERE updated_at > ?', [since]);
Enter fullscreen mode Exit fullscreen mode

WatermelonDB: an ORM on top of SQLite with reactive queries and a sync protocol. Reach for it when you have tens of thousands of records and two-way sync.

Realm: a reactive object database with its own engine. It still works well locally, but MongoDB ended Atlas Device Sync in September 2025, so don't plan on hosted sync.

The decision table

Need Pick Why
Secrets, tokens Expo SecureStore OS-level encryption
Preferences, flags, hot reads MMKV Fast synchronous reads
Structured, queryable records expo-sqlite SQL, indexes, joins
Large offline-first data with sync WatermelonDB Reactive queries + sync
Reactive object DB, local only Realm Clean object API

Most production apps use three at once: SecureStore + MMKV + SQLite. They don't conflict; each owns a different concern.

The offline-first architecture (the part everyone skips)

Picking a library is the easy part. The architecture around it is what makes an app work offline.

1. Hydrate from local storage on launch, refresh in the background

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

Users should never see a blank screen because of a network request.

2. Queue writes while offline

On every change, store two things: the local row (so the UI updates instantly) and an entry in a sync queue.

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

3. Drain the queue when the network is really back

Check isInternetReachable, not just isConnected: a captive portal Wi-Fi reports "connected" while nothing actually loads.

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

const API_URL = process.env.EXPO_PUBLIC_API_URL;
let draining = false;

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

  try {
    const pending = db.getAllSync(
      'SELECT * FROM mutation_queue ORDER BY created_at ASC'
    );

    for (const m of pending) {
      try {
        const res = await fetch(`${API_URL}/${m.type}`, {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'Idempotency-Key': m.id,
          },
          body: m.payload,
        });
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        db.runSync('DELETE FROM mutation_queue WHERE id = ?', [m.id]);
      } catch {
        db.runSync(
          'UPDATE mutation_queue SET attempts = attempts + 1 WHERE id = ?',
          [m.id]
        );
        break; // keep order; retry on the next reconnect
      }
    }
  } finally {
    draining = false;
  }
});
Enter fullscreen mode Exit fullscreen mode

Three details matter here:

  • Idempotency-Key: without it, a flaky network creates duplicates whenever a request succeeds on the server but the response never arrives. Your server must store and honor the key.
  • The draining flag: NetInfo can fire several events in a row. Without a guard, two loops send the same mutations at once.
  • Checking res.ok: fetch only throws on network failure, not on a 500. Without the check, failed writes get deleted from the queue.

4. Resolve conflicts explicitly

Three strategies:

  1. Last write wins: compare timestamps. Simple, but can lose data. Fine for most single-user apps.
  2. Server-authoritative merge: the server applies changes in order and returns the canonical state.
  3. CRDTs (Yjs, Automerge): needed for real-time collaborative editing. Heavier, but correct.

Five pitfalls to avoid

  1. Serializing giant arrays into AsyncStorage. A classic cause of slow launches. Move large data to SQLite.
  2. Hard-coding an MMKV encryptionKey. Encryption is opt-in, and a key in your source code is readable by anyone. Generate a random key on first launch and keep it in SecureStore.
  3. Trusting isConnected. Use isInternetReachable.
  4. Retrying without idempotency keys. You'll create duplicate records.
  5. Loading huge tables with getAllSync. Synchronous reads block the JS thread. Paginate, add indexes, or use the async API for large queries.

Want this scaffolded for you?

If you're starting a new React Native app and want SecureStore + MMKV + SQLite pre-wired with hydration on launch and a sync queue skeleton, RapidNative generates it from a plain-English prompt and lets you export the full Expo project.

Everything in this guide is a pattern you can copy by hand too; the whole stack is open source and well documented. Pick the engines that match your access patterns, wrap them in an offline-first architecture, and your app will feel instant whether the network is there or not.

Top comments (0)