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.
Faisal Khawaj
Author
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
| Piece | Role in offline-first |
|---|---|
| Local DB (SQLite / MMKV) | Primary source of truth for the UI |
| Sync queue | Durable pending creates / updates / deletes |
| NetInfo | Reconnect + foreground sync triggers |
| TanStack Query | Optimistic UI and mutation orchestration |
| NestJS + PostgreSQL | Server 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:
| Capability | Why it matters |
|---|---|
| Read local or previously synced data | No blank screens |
| Keep doing meaningful work offline | Forms, drafts, likes, notes |
| Persist actions across restarts | Queue must be durable |
| Sync automatically when possible | Users shouldn’t babysit the network |
| Resolve or surface conflicts | Two devices will diverge |
| Show sync state clearly | Trust 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
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) → PostgreSQLHappy path for a write:
User action
→ write local DB
→ update UI immediately
→ enqueue sync job
→ attempt server sync when possibleThe user never waits on the network to see their change.
| Layer | Job |
|---|---|
| UI | Render local/cache data; show pending / failed |
| TanStack Query | Mutations, optimistic updates, invalidation |
| Persistence | Durable source of truth on device |
| Sync queue | Pending creates / updates / deletes |
| Sync engine | Push / pull / mark synced / retry |
| Conflict resolver | Policy 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.
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:
- Generate a local ID (
local-…) - Persist immediately with
syncStatus: "pending" - Enqueue a sync operation
- Try sync if online
- Map
serverIdwhen the API accepts it
Sketch with a thin repository (use SQLite in production; shown conceptually):
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.
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)
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
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)
| Policy | Fits |
|---|---|
| Last write wins | Low-collision notes, simple profiles |
| Server wins | Inventory, pricing, compliance records |
| Client wins | Rare — draft-only domains |
| Version / ETag | Collaborative edits |
| Field-level merge | Profiles, structured forms |
On NestJS, reject stale versions explicitly:
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
- Trusting
isConnectedalone — handle real HTTP failures. - Caching reads only — writes are the hard part.
- In-memory queues — restart = lost work.
- No conflict policy — “we’ll see” becomes support tickets.
- Hard deletes before sync — use tombstones until confirmed.
- Retry storms — backoff or die trying (politely).
- 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
| Role | Choice |
|---|---|
| Connectivity | @react-native-community/netinfo |
| Mutation / cache UX | TanStack Query |
| Fast key-value | MMKV |
| Domain persistence | SQLite or WatermelonDB |
| Sync | Custom durable queue + reconnect/foreground |
| API | NestJS (or similar) |
| Server DB | PostgreSQL |
| Auth | JWT + secure storage (Firebase Auth optional) |
Install the basics (Expo example):
npx expo install @react-native-community/netinfo expo-sqlite
npm install @tanstack/react-query react-native-mmkvFAQ: 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:
- Multi-Environment Setup for React Native CLI — Dev / Staging / Production flavors and schemes
- How to Integrate Calendly in a Next.js App — booking embeds for freelance sites
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.