Blog
React NativeOffline FirstOffline AppsReact Native OfflineReact Native Offline FirstReact Native Offline StorageMobile DevelopmentSQLiteReact Native SQLiteMMKVReact Native MMKVReact QueryTanStack QueryNetInfoReact Native NetInfoBackground SyncReact Native Background SyncSync QueueLocal DatabaseReact Native CachingReact Native Best PracticesNestJSPostgreSQLWatermelonDB

Offline-First React Native: SQLite, MMKV, Sync Queue & NetInfo

A production-ready guide to offline-first React Native apps — local storage with SQLite and MMKV, durable sync queues, NetInfo, TanStack Query optimistic updates, and background sync.

FK

Faisal Khawaj

Author

13 min read

If your React Native app freezes, blanks out, or loses a tap the moment the signal drops, users notice in seconds. Real networks are messy: elevators, basements, rural roads, trains, airplane mode, and “connected” Wi-Fi that cannot reach your API.

Offline-first React Native is how you design for that world. The local database owns what the user sees. The server catches up when it can. Your job is to make that handoff invisible — and honest when it isn’t.

This production guide covers React Native offline storage (SQLite / MMKV), NetInfo, durable sync queues, TanStack Query optimistic updates, conflict rules, UX trust, testing, and the mistakes that burn teams — the same patterns I use when shipping App Store and Google Play apps.

Building multi-env React Native releases next? See Multi-Environment Setup for React Native CLI — Android flavors, iOS schemes, and react-native-config.

What you'll build

PieceRole in offline-first
Local DB (SQLite / MMKV)Primary source of truth for the UI
Sync queueDurable pending creates / updates / deletes
NetInfoReconnect + foreground sync triggers
TanStack QueryOptimistic UI and mutation orchestration
NestJS + PostgreSQLServer of record when online

By the end you can ship a React Native app that keeps working offline, syncs safely when online, and never silently loses user work.

Prerequisites

  • A React Native project (Expo or bare CLI)
  • Familiarity with REST (or GraphQL) mutations
  • Basic TanStack Query / React Query experience helps

What offline-first React Native really means

A lot of apps market “offline mode” when they only mean:

  • Show a red banner
  • Keep a few GET responses in memory
  • Block every write until the API returns 200

That is offline-tolerant UI, not offline-first product design.

A real offline-first app should:

CapabilityWhy it matters
Read local or previously synced dataNo blank screens
Keep doing meaningful work offlineForms, drafts, likes, notes
Persist actions across restartsQueue must be durable
Sync automatically when possibleUsers shouldn’t babysit the network
Resolve or surface conflictsTwo devices will diverge
Show sync state clearlyTrust beats silent failure

Mindset: the UI’s source of truth is on-device. The backend is the durable, multi-device sync layer — not the gatekeeper of every tap.


When product owners should insist on offline-first

Invest early if users create or review data where coverage is unreliable:

  • Field ops, logistics, inspections
  • Healthcare forms and visit notes
  • Sales CRMs on the road
  • Travel, education, and checklists
  • Messaging / social drafts
  • Productivity apps (notes, tasks, surveys)

If the core job only works with a perfect connection, offline-first is a quality requirement — not a nice-to-have sprint at the end.

You don’t need every feature offline. Real-time trading, live multiplayer, and strict server-authoritative workflows can stay online-only. Degrade those features explicitly; don’t pretend they work.


Five principles before you write code

1. Local-first UI

Screens read from local storage first. Network is enrichment, not a blocker for first paint.

2. Sync is async

Creating a note and confirming it on the server are two events. Local success can happen now; server ack later.

3. Every write carries metadata

You need enough metadata to reconcile later: syncStatus, temp IDs, updatedAt / version, retry counts, optional tombstones for deletes.

4. “Online” is a hint, not a guarantee

NetInfo saying connected does not mean your POST will succeed. Always handle request failure.

5. Conflict policy is product behavior

Last-write-wins, server-wins, or merge — pick it with the product owner, then encode it. Don’t invent it during a production outage.


Architecture that holds up in production

Code
React Native screens
        │
        ▼
TanStack Query (orchestration, optimistic UI, retries)
        │
        ▼
Local persistence (MMKV / SQLite / WatermelonDB)
        │
        ▼
Durable sync queue
        │
        ▼
Network monitor (NetInfo) + reconnect / foreground sync
        │
        ▼
API (NestJS) → PostgreSQL

Happy path for a write:

Code
User action
  → write local DB
  → update UI immediately
  → enqueue sync job
  → attempt server sync when possible

The user never waits on the network to see their change.

LayerJob
UIRender local/cache data; show pending / failed
TanStack QueryMutations, optimistic updates, invalidation
PersistenceDurable source of truth on device
Sync queuePending creates / updates / deletes
Sync enginePush / pull / mark synced / retry
Conflict resolverPolicy when local ≠ remote

React Native offline storage: MMKV vs SQLite vs WatermelonDB

MMKV

Use for: tokens flags, settings, small JSON blobs, feature flags.

Skip for: query-heavy lists, relationships, large catalogs.

Extremely fast and often synchronous — great as a companion store, rarely as your only database for a CRM or feed.

SQLite (expo-sqlite, op-sqlite, etc.)

Use for: posts, messages, orders, filters, joins, anything you’d put in a real schema.

Why teams stick with it: relational, scalable, you own the sync logic.

WatermelonDB

Use for: large local datasets, observable queries, apps where offline is the product.

Trade-off: more structure and conventions — worth it when sync and list performance are central.

AsyncStorage

Fine for prototypes and tiny prefs. For production offline-first with growing models, serializing whole arrays gets slow and brittle. Graduate early.

Practical combo I ship often: MMKV for prefs/tokens + SQLite (or WatermelonDB) for domain records + TanStack Query for mutation UX.


Step 1 — React Native NetInfo for connectivity

Connectivity detection improves UX, but a banner alone is not architecture.

TypeScript
import { useEffect, useState } from "react";
import NetInfo from "@react-native-community/netinfo";

export function useIsOnline() {
  const [online, setOnline] = useState(true);

  useEffect(() => {
    const unsub = NetInfo.addEventListener((state) => {
      setOnline(Boolean(state.isConnected && state.isInternetReachable !== false));
    });
    return unsub;
  }, []);

  return online;
}

Show something like: You’re offline. Changes will sync when you’re back. Then make sure that promise is true.


Step 2 — Write local first (notes example)

Anti-pattern: call POST /notes, then update UI only on success.

Offline-first flow:

  1. Generate a local ID (local-…)
  2. Persist immediately with syncStatus: "pending"
  3. Enqueue a sync operation
  4. Try sync if online
  5. Map serverId when the API accepts it

Sketch with a thin repository (use SQLite in production; shown conceptually):

TypeScript
type SyncStatus = "synced" | "pending" | "failed";

type Note = {
  id: string;
  title: string;
  content: string;
  updatedAt: string;
  syncStatus: SyncStatus;
  serverId?: string;
};

export async function createNoteLocally(title: string, content: string): Promise<Note> {
  const note: Note = {
    id: `local-${Date.now()}`,
    title,
    content,
    updatedAt: new Date().toISOString(),
    syncStatus: "pending",
  };

  await notesRepo.insert(note);
  await syncQueue.enqueue({
    id: `op-${Date.now()}`,
    type: "create",
    entity: "note",
    entityId: note.id,
    payload: note,
    createdAt: new Date().toISOString(),
    retryCount: 0,
  });

  return note;
}

Temporary IDs are normal. The sync engine later rewrites references when the server returns a permanent ID.


Step 3 — Durable React Native sync queue

If the user kills the app mid-flight, queued work must still be there.

TypeScript
type SyncOperation = {
  id: string;
  type: "create" | "update" | "delete";
  entity: "note";
  entityId: string;
  payload: Record<string, unknown>;
  createdAt: string;
  retryCount: number;
  lastAttemptAt?: string;
};

Rules that save you later:

  • Persist the queue in SQLite/MMKV, not only React state
  • Cap retries, then move to a dead-letter list the user (or support) can see
  • Prefer tombstones for deletes until the server confirms — don’t hard-delete early or you lose the sync intent

Step 4 — Process the queue on reconnect (and foreground)

TypeScript
import NetInfo from "@react-native-community/netinfo";

export async function processSyncQueue(api: NotesApi) {
  const net = await NetInfo.fetch();
  if (!net.isConnected) return;

  const queue = await syncQueue.list();
  const leftover: SyncOperation[] = [];

  for (const op of queue) {
    try {
      const result = await api.apply(op);
      await notesRepo.markSynced(op.entityId, result.serverId);
    } catch {
      leftover.push({
        ...op,
        retryCount: op.retryCount + 1,
        lastAttemptAt: new Date().toISOString(),
      });
    }
  }

  await syncQueue.replace(leftover.filter((op) => op.retryCount < 5));
  // push exhausted ops to dead-letter storage
}

Trigger points that cover most apps:

  • NetInfo flips to connected
  • App returns to foreground
  • Optional: light background fetch (iOS is strict — don’t bet the product on it)

Step 5 — TanStack Query optimistic updates (offline-aware)

TanStack Query shines for UX orchestration. It does not replace a durable DB for serious offline writes.

Pattern:

  • UI reads from local repo (or hydrated Query cache)
  • Mutation writes local + queue first
  • Query optimistic update keeps lists snappy
  • On settle, invalidate or patch from local truth
TypeScript
import { useMutation, useQueryClient } from "@tanstack/react-query";

export function useToggleLike() {
  const qc = useQueryClient();

  return useMutation({
    mutationFn: async (postId: string) => {
      // Prefer: local write + enqueue; API may no-op when offline
      return postsRepo.likeLocalThenQueue(postId);
    },
    onMutate: async (postId) => {
      await qc.cancelQueries({ queryKey: ["posts"] });
      const previous = qc.getQueryData(["posts"]);

      qc.setQueryData(["posts"], (old: Post[] | undefined) =>
        (old ?? []).map((p) =>
          p.id === postId
            ? { ...p, liked: true, likeCount: p.likeCount + 1, syncStatus: "pending" }
            : p,
        ),
      );

      return { previous };
    },
    onError: (_e, _id, ctx) => {
      if (ctx?.previous) qc.setQueryData(["posts"], ctx.previous);
    },
    onSettled: () => qc.invalidateQueries({ queryKey: ["posts"] }),
  });
}

Rule of thumb: React Query for coordination; SQLite/Watermelon for durability across restarts.


Conflict resolution (decide before launch)

PolicyFits
Last write winsLow-collision notes, simple profiles
Server winsInventory, pricing, compliance records
Client winsRare — draft-only domains
Version / ETagCollaborative edits
Field-level mergeProfiles, structured forms

On NestJS, reject stale versions explicitly:

TypeScript
if (dto.version < existing.version) {
  throw new ConflictException({
    code: "STALE_VERSION",
    serverRecord: existing,
  });
}

The client should either merge, overwrite with server, or open a “resolve” UI — never silently drop the user’s work.


Retries without melting the battery

Instant infinite retries punish devices and backends.

Simple backoff: 5s → 15s → 30s → 1m → longer, capped. Store retryCount and lastAttemptAt on each queue item. After max attempts, dead-letter and show a Retry control.


UX that builds trust

Offline is half plumbing, half communication.

Ship these:

  • Offline banner (or subtle status chip)
  • Per-item pending / synced / failed indicators
  • Manual “Retry sync”
  • Pull-to-refresh when back online
  • Draft preservation that says “Saved on this device”
  • Honest copy when a feature is server-only (remote search, live payments, etc.)

Users forgive delay. They don’t forgive silent data loss.


Security on-device

Local DBs are attack surface.

  • Prefer Keychain / Keystore + encrypted MMKV for secrets
  • Never persist passwords or full card data
  • Wipe local domain data on logout
  • Short-lived access tokens + refresh rotation
  • Server always validates — never trust the client payload as gospel
  • Expire sensitive caches (PHI / PII) by policy when compliance applies

Performance habits that keep sync cheap

  • Paginate and TTL stale lists
  • Don’t load entire tables into memory
  • Compress images before upload
  • Batch small mutations when the API allows
  • Prune old synced rows / cache by content type

Mistakes I still see in code reviews

  1. Trusting isConnected alone — handle real HTTP failures.
  2. Caching reads only — writes are the hard part.
  3. In-memory queues — restart = lost work.
  4. No conflict policy — “we’ll see” becomes support tickets.
  5. Hard deletes before sync — use tombstones until confirmed.
  6. Retry storms — backoff or die trying (politely).
  7. Maximum architecture on day one — start with one-way push of a queue; add pull/merge when the product needs it.

Testing checklist (don’t skip)

Online happy path is not enough. Exercise:

  • Cold launch with airplane mode
  • Create / update / delete while offline
  • Kill app before sync, relaunch, reconnect
  • Multiple queued ops of mixed types
  • Server 500 mid-sync (partial success)
  • Same record edited twice offline
  • Create then delete before reconnect
  • Duplicate prevention if a retry succeeds after a timeout
  • Slow 3G, not only full disconnect

These edge cases are where offline bugs live.


Stack I recommend for most client apps

RoleChoice
Connectivity@react-native-community/netinfo
Mutation / cache UXTanStack Query
Fast key-valueMMKV
Domain persistenceSQLite or WatermelonDB
SyncCustom durable queue + reconnect/foreground
APINestJS (or similar)
Server DBPostgreSQL
AuthJWT + secure storage (Firebase Auth optional)

Install the basics (Expo example):

Terminal
npx expo install @react-native-community/netinfo expo-sqlite
npm install @tanstack/react-query react-native-mmkv

FAQ: Offline-first React Native

What is offline-first in React Native?

Offline-first means the local database is the UI’s primary source of truth. Users can read and write without a network. A durable sync queue pushes changes when connectivity returns.

Should I use AsyncStorage, MMKV, or SQLite?

MMKV for tokens and settings. SQLite or WatermelonDB for structured data (posts, messages, forms). AsyncStorage is fine for prototypes — not for production sync with growing models.

How do I sync offline changes?

Write locally → enqueue mutation → process the queue on NetInfo reconnect or app foreground → retry with backoff → dead-letter permanent failures.

Does TanStack Query replace a local database?

No. Use TanStack Query for optimistic UI and orchestration. Use SQLite/MMKV so data and pending writes survive app restarts.

How do you handle sync conflicts?

Decide early: last write wins, server wins, or version-based 409 rejection. Surface merge/retry in the UI — never drop user work silently.


Closing

Offline-first React Native is not a banner and a cache. It’s local-first UI, a durable sync queue, honest sync status, and product-level conflict rules. Done well, the app feels faster even on good Wi-Fi — because every interaction was designed around reality, not lab conditions.

Next reads on this site:


Need offline-first architecture on a React Native product? Book intro call or Start a project — I ship App Store and Google Play apps with sync that survives tunnels, flights, and flaky hotel Wi-Fi.

Published Jul 21, 2026 · 13 min read