Back to blog
React NativeOffline FirstMobile DevelopmentSQLiteMMKVReact QueryTanStack QueryNetInfoBackground SyncReact Native Best PracticesNestJSPostgreSQLLocal DatabaseReact Native CachingReact Native Offline First

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.

FK

Faisal Khawaj

Author

9 min read

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.

Code
User Action
     │
     ▼
Local Database
     │
     ▼
UI Updates Instantly
     │
     ▼
Background Sync
     │
     ▼
Backend API

This 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

Code
React Native UI
       │
       ▼
TanStack Query (cache + mutations)
       │
       ▼
Local Storage (SQLite / MMKV)
       │
       ▼
Sync Queue
       │
       ▼
API Server (NestJS)
       │
       ▼
PostgreSQL
LayerResponsibility
UIRenders from local/cache data immediately
TanStack QueryCache, mutations, optimistic updates
Local storagePersistent source of truth on device
Sync queueHolds pending writes until online
APIValidates, 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.

TypeScript
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:

Terminal
npm install @react-native-community/netinfo
TypeScript
import 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.

Code
User likes a post
       │
       ▼
Save locally
       │
       ▼
Add to Sync Queue
       │
       ▼
Show ❤️ instantly

--- later ---

Internet restored
       │
       ▼
Process queue
       │
       ▼
Backend updated

A minimal sync queue

TypeScript
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:

  1. Fetch pending operations from the queue
  2. Execute API requests in order (or by priority)
  3. Mark completed items as done
  4. Retry failed requests with backoff
  5. Update TanStack Query cache with server response
TypeScript
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.

TypeScript
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?

StrategyWhen to use
Last write winsSimple apps, low collision risk
Server winsAuthoritative server data (inventory, pricing)
Client winsRare — user drafts, local notes
Version numbersCollaborative apps, documents
MergeText 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.

TypeScript
// 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
TypeScript
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

PurposeTechnology
UI state & cacheTanStack Query
Key-value storageMMKV
Relational local DBSQLite / WatermelonDB
Network detectionNetInfo
HTTP clientAxios
Background tasksBackground Fetch
Backend APINestJS
Server databasePostgreSQL
AuthFirebase 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.

Published Jul 9, 2026 · 9 min read