Offline-First Mobile Apps in React Native: A Complete Production Guide
Build production-ready offline-first React Native apps — local storage with SQLite and MMKV, sync queues, NetInfo, optimistic updates with TanStack Query, conflict resolution, and NestJS backend patterns.
Faisal Khawaj
Author
Users expect mobile apps to work everywhere — even with poor or no internet connection.
Whether it's a messaging app, field service tool, e-commerce platform, or social network, an offline-first architecture lets users keep interacting without interruptions. Data is stored locally first and synchronized with the backend when connectivity returns.
This guide walks through a production-ready offline-first stack in React Native using tools I've shipped in real apps: TanStack Query, SQLite, MMKV, NetInfo, and a NestJS + PostgreSQL backend.
What you'll learn
- What offline-first means and why it matters
- How to choose between MMKV, SQLite, and WatermelonDB
- Network detection with NetInfo
- Sync queues for deferred API requests
- Optimistic updates with TanStack Query
- Conflict resolution and retry strategies
- Security and performance best practices
Prerequisites
- A React Native project (Expo or bare CLI)
- Familiarity with REST APIs and async JavaScript
- Basic understanding of React Query / TanStack Query
What is offline-first?
An offline-first application treats the local database as the primary source of truth.
Instead of depending on the server for every interaction, data is written locally first and synchronized with the backend whenever connectivity is available.
User Action
│
▼
Local Database
│
▼
UI Updates Instantly
│
▼
Background Sync
│
▼
Backend APIThis approach delivers:
- Instant UI updates — no spinners for every tap
- Better UX — apps feel native and responsive
- Fewer API requests — batch and defer when possible
- Reliable persistence — data survives app restarts
- Faster performance — reads come from local storage
Why offline-first matters
Without offline support
- Blank screens during network outages
- Lost user actions (likes, form submissions, messages)
- Frustrating loading states on every screen
- Poor performance on slow networks
- Total dependency on API availability
With offline-first
- Users browse cached content offline
- Forms continue working and queue for sync
- Likes and comments show instantly
- Messages synchronize when back online
- No data loss from dropped connections
Architecture overview
React Native UI
│
▼
TanStack Query (cache + mutations)
│
▼
Local Storage (SQLite / MMKV)
│
▼
Sync Queue
│
▼
API Server (NestJS)
│
▼
PostgreSQL| Layer | Responsibility |
|---|---|
| UI | Renders from local/cache data immediately |
| TanStack Query | Cache, mutations, optimistic updates |
| Local storage | Persistent source of truth on device |
| Sync queue | Holds pending writes until online |
| API | Validates, merges, persists server-side |
Choosing local storage
MMKV
Best for small, fast key-value data:
- Auth tokens
- User settings
- Feature flags
- Small cached objects
Pros: extremely fast, synchronous API, lightweight.
import { MMKV } from "react-native-mmkv";
export const storage = new MMKV({ id: "app-storage" });
storage.set("user.settings", JSON.stringify({ theme: "dark" }));
const settings = JSON.parse(storage.getString("user.settings") ?? "{}");SQLite
Best for structured, queryable data:
- Posts, messages, orders
- Large datasets with relationships
- Offline search and filtering
Pros: relational, efficient queries, scales well.
Popular options: op-sqlite, react-native-quick-sqlite, or WatermelonDB (SQLite + sync layer).
WatermelonDB
Ideal for large enterprise apps:
- Thousands of records
- Complex synchronization
- Observable queries that auto-update the UI
Use when your app has heavy lists (feeds, inboxes, catalogs) and you need a full offline ORM — not for a simple settings screen.
Detect network status
Use @react-native-community/netinfo to react to connectivity changes:
npm install @react-native-community/netinfoimport NetInfo from "@react-native-community/netinfo";
import { useEffect, useState } from "react";
export function useNetworkStatus() {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
const unsubscribe = NetInfo.addEventListener((state) => {
setIsOnline(Boolean(state.isConnected && state.isInternetReachable));
});
return unsubscribe;
}, []);
return isOnline;
}When isOnline flips to true, trigger your sync processor.
Queue API requests
Don't block the UI waiting for the network. Write locally, queue the API call, show success immediately.
User likes a post
│
▼
Save locally
│
▼
Add to Sync Queue
│
▼
Show ❤️ instantly
--- later ---
Internet restored
│
▼
Process queue
│
▼
Backend updatedA minimal sync queue
type QueuedMutation = {
id: string;
type: "like" | "comment" | "create_post";
payload: Record<string, unknown>;
retries: number;
createdAt: number;
};
const MAX_RETRIES = 5;
export class SyncQueue {
private queue: QueuedMutation[] = [];
enqueue(item: Omit<QueuedMutation, "retries" | "createdAt">) {
this.queue.push({
...item,
retries: 0,
createdAt: Date.now(),
});
this.persist();
}
async process(api: { execute: (item: QueuedMutation) => Promise<void> }) {
for (const item of [...this.queue]) {
try {
await api.execute(item);
this.remove(item.id);
} catch {
item.retries += 1;
if (item.retries >= MAX_RETRIES) {
this.moveToDeadLetter(item);
this.remove(item.id);
}
}
}
this.persist();
}
private remove(id: string) {
this.queue = this.queue.filter((q) => q.id !== id);
}
private persist() {
// Save to MMKV or SQLite
}
private moveToDeadLetter(item: QueuedMutation) {
// Log for debugging / manual retry UI
}
}Run process() when NetInfo reports connectivity and on app foreground.
Background synchronization
When connectivity returns, your sync worker should:
- Fetch pending operations from the queue
- Execute API requests in order (or by priority)
- Mark completed items as done
- Retry failed requests with backoff
- Update TanStack Query cache with server response
import NetInfo from "@react-native-community/netinfo";
import { syncQueue } from "./sync-queue";
import { api } from "./api";
NetInfo.addEventListener(async (state) => {
if (state.isConnected) {
await syncQueue.process({
execute: async (item) => {
await api.mutate(item.type, item.payload);
},
});
}
});For iOS/Android background fetch (periodic sync while app is backgrounded), use expo-background-fetch or react-native-background-fetch — register a task that calls the same process() function.
Optimistic updates with TanStack Query
Instead of waiting for POST /like to finish, update the UI immediately. Roll back if the request fails.
import { useMutation, useQueryClient } from "@tanstack/react-query";
function useLikePost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (postId: string) => api.likePost(postId),
onMutate: async (postId) => {
await queryClient.cancelQueries({ queryKey: ["posts"] });
const previous = queryClient.getQueryData(["posts"]);
queryClient.setQueryData(["posts"], (old: Post[]) =>
old.map((p) =>
p.id === postId ? { ...p, liked: true, likeCount: p.likeCount + 1 } : p,
),
);
return { previous };
},
onError: (_err, _postId, context) => {
queryClient.setQueryData(["posts"], context?.previous);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["posts"] });
},
});
}If offline, enqueue the mutation in your sync queue inside onMutate instead of calling mutationFn immediately.
Conflict resolution
What happens if two devices edit the same record offline?
| Strategy | When to use |
|---|---|
| Last write wins | Simple apps, low collision risk |
| Server wins | Authoritative server data (inventory, pricing) |
| Client wins | Rare — user drafts, local notes |
| Version numbers | Collaborative apps, documents |
| Merge | Text fields, structured merges |
For collaborative apps, add a version or updatedAt field. The server rejects stale writes with 409 Conflict and returns the latest record so the client can merge or prompt the user.
// NestJS guard pattern
if (dto.version < existing.version) {
throw new ConflictException({ serverRecord: existing });
}Retry strategy
Retry intelligently — not every second.
- Retry after reconnect — primary trigger via NetInfo
- Exponential backoff — 1s, 2s, 4s, 8s…
- Max attempts — e.g. 5 retries then dead-letter
- Dead-letter queue — surface failed items in a "Sync issues" UI
function backoffMs(attempt: number): number {
return Math.min(1000 * 2 ** attempt, 30_000);
}Avoid hammering the API on a flaky connection — it drains battery and annoys users.
Security
Offline storage must not expose sensitive data.
- Store tokens in encrypted storage (
react-native-keychain, MMKV with encryption) - Never cache passwords or full payment details
- Clear local data on logout
- Use short-lived JWTs + refresh token rotation
- Validate all synced data on the server — never trust the client
Performance tips
- Cache only what's needed — paginate feeds, expire stale data
- Paginate large datasets — don't load 10,000 rows into memory
- Compress images before upload (
react-native-compressor) - Batch sync requests — combine multiple likes into one API call where possible
- Prune stale cache — TTL per content type (news vs profile)
Recommended tech stack
| Purpose | Technology |
|---|---|
| UI state & cache | TanStack Query |
| Key-value storage | MMKV |
| Relational local DB | SQLite / WatermelonDB |
| Network detection | NetInfo |
| HTTP client | Axios |
| Background tasks | Background Fetch |
| Backend API | NestJS |
| Server database | PostgreSQL |
| Auth | Firebase Auth / JWT |
Real-world use cases
Offline-first architecture is standard in:
- Messaging apps — queue messages, sync on reconnect
- CRM / field service — technicians work without signal
- Food delivery — riders and restaurants in low-coverage areas
- Healthcare — patient forms in hospitals with weak Wi-Fi
- E-commerce — browse catalog, cart persists offline
- Social networks — feed cache, queued interactions
If your users move through tunnels, rural areas, or congested networks, offline-first isn't optional — it's expected.
Conclusion
Offline-first is more than caching — it's a design philosophy that prioritizes user experience regardless of network conditions.
By combining local persistence, intelligent synchronization, optimistic updates, and background processing, you can build React Native apps that stay fast, reliable, and resilient in the real world.
Building a mobile app that needs offline support? Book a 15-min call or get in touch — I ship React Native apps to the App Store and Google Play with production-grade sync architecture.