Blog/Tutorials & Step-by-Step/Build an Offline-First React Native App
POST
September 15, 2025
LAST UPDATEDSeptember 15, 2025

Build an Offline-First React Native App

Learn how to build a React Native app that works seamlessly offline with MMKV storage, queue-based sync, and conflict resolution strategies.

Tags

React NativeOffline-FirstMobileSync
Build an Offline-First React Native App
5 min read

Build an Offline-First React Native App

In this tutorial, you will build a fully offline-capable React Native application that stores data locally using MMKV, detects network changes in real time, queues mutations for later sync, resolves conflicts gracefully, and updates the UI optimistically so your users never feel like they are waiting. By the end, you will have a production-grade sync architecture you can apply to any mobile project.

TL;DR

Use MMKV for blazing-fast local storage, NetInfo for connectivity detection, a persistent operation queue for deferred syncing, and a last-write-wins or merge-based strategy for conflict resolution. Wrap it all in a context provider so every screen in your app shares a single source of truth.

Prerequisites

  • React Native 0.72+ (or Expo SDK 49+)
  • Node.js 18+
  • Basic familiarity with React hooks and context
  • A REST or GraphQL backend to sync with (we will use a simple Express API for examples)

Install the core dependencies before starting:

bash
npx react-native init OfflineApp
cd OfflineApp
npm install react-native-mmkv @react-native-community/netinfo uuid react-native-background-fetch
npx pod-install

Step 1: Set Up MMKV for Local Storage

MMKV is a high-performance key-value storage library originally built by WeChat. Unlike AsyncStorage, it uses a synchronous C++ bridge, making reads and writes nearly instantaneous.

typescript
// src/storage/mmkv.ts
import { MMKV } from "react-native-mmkv";
 
export const storage = new MMKV({
  id: "offline-app-storage",
  encryptionKey: "your-encryption-key", // optional but recommended
});
 
// Generic typed helpers
export function setItem<T>(key: string, value: T): void {
  storage.set(key, JSON.stringify(value));
}
 
export function getItem<T>(key: string): T | null {
  const raw = storage.getString(key);
  if (!raw) return null;
  try {
    return JSON.parse(raw) as T;
  } catch {
    return null;
  }
}
 
export function removeItem(key: string): void {
  storage.delete(key);
}
 
export function getAllKeys(): string[] {
  return storage.getAllKeys();
}

The setItem and getItem wrappers handle JSON serialization so the rest of your code can work with typed objects instead of raw strings. Encryption is optional but highly recommended if you store any user-sensitive data.

Step 2: Build a Network Monitor

Reliable network detection is the backbone of any offline-first architecture. We use @react-native-community/netinfo wrapped in a React context so every component can react to connectivity changes.

typescript
// src/context/NetworkContext.tsx
import React, { createContext, useContext, useEffect, useState, ReactNode } from "react";
import NetInfo, { NetInfoState } from "@react-native-community/netinfo";
 
interface NetworkContextValue {
  isConnected: boolean;
  isInternetReachable: boolean | null;
  connectionType: string | null;
}
 
const NetworkContext = createContext<NetworkContextValue>({
  isConnected: true,
  isInternetReachable: null,
  connectionType: null,
});
 
export function NetworkProvider({ children }: { children: ReactNode }) {
  const [state, setState] = useState<NetworkContextValue>({
    isConnected: true,
    isInternetReachable: null,
    connectionType: null,
  });
 
  useEffect(() => {
    const unsubscribe = NetInfo.addEventListener((netState: NetInfoState) => {
      setState({
        isConnected: netState.isConnected ?? false,
        isInternetReachable: netState.isInternetReachable,
        connectionType: netState.type,
      });
    });
 
    return () => unsubscribe();
  }, []);
 
  return (
    <NetworkContext.Provider value={state}>
      {children}
    </NetworkContext.Provider>
  );
}
 
export function useNetwork(): NetworkContextValue {
  return useContext(NetworkContext);
}

Notice we track both isConnected and isInternetReachable. A device can be connected to Wi-Fi but have no actual internet access. Checking both prevents false positives.

Step 3: Design the Operation Queue

When the user is offline, every create, update, or delete action goes into a persistent queue. Each operation is serialized to MMKV so it survives app restarts.

typescript
// src/sync/operationQueue.ts
import { v4 as uuidv4 } from "uuid";
import { getItem, setItem } from "../storage/mmkv";
 
export interface QueuedOperation {
  id: string;
  type: "CREATE" | "UPDATE" | "DELETE";
  entity: string;         // e.g., "tasks", "notes"
  entityId: string;
  payload: Record<string, unknown>;
  timestamp: number;
  retryCount: number;
  maxRetries: number;
}
 
const QUEUE_KEY = "sync_operation_queue";
 
export function getQueue(): QueuedOperation[] {
  return getItem<QueuedOperation[]>(QUEUE_KEY) ?? [];
}
 
function saveQueue(queue: QueuedOperation[]): void {
  setItem(QUEUE_KEY, queue);
}
 
export function enqueue(
  type: QueuedOperation["type"],
  entity: string,
  entityId: string,
  payload: Record<string, unknown>
): QueuedOperation {
  const operation: QueuedOperation = {
    id: uuidv4(),
    type,
    entity,
    entityId,
    payload,
    timestamp: Date.now(),
    retryCount: 0,
    maxRetries: 5,
  };
 
  const queue = getQueue();
  queue.push(operation);
  saveQueue(queue);
 
  return operation;
}
 
export function dequeue(operationId: string): void {
  const queue = getQueue().filter((op) => op.id !== operationId);
  saveQueue(queue);
}
 
export function incrementRetry(operationId: string): boolean {
  const queue = getQueue();
  const op = queue.find((o) => o.id === operationId);
  if (!op) return false;
 
  op.retryCount += 1;
  if (op.retryCount >= op.maxRetries) {
    // Move to dead letter queue for manual inspection
    const deadLetters = getItem<QueuedOperation[]>("dead_letter_queue") ?? [];
    deadLetters.push(op);
    setItem("dead_letter_queue", deadLetters);
    saveQueue(queue.filter((o) => o.id !== operationId));
    return false;
  }
 
  saveQueue(queue);
  return true;
}

The dead letter queue catches operations that fail repeatedly. In production, you would surface these to the user or send them to an error tracking service.

Step 4: Build the Sync Engine

The sync engine processes the queue when the device comes back online. It sends each operation to the server in order and handles failures gracefully.

typescript
// src/sync/syncEngine.ts
import { getQueue, dequeue, incrementRetry, QueuedOperation } from "./operationQueue";
 
const API_BASE = "https://api.yourapp.com";
 
async function executeOperation(op: QueuedOperation): Promise<boolean> {
  const endpoint = `${API_BASE}/${op.entity}`;
 
  try {
    let response: Response;
 
    switch (op.type) {
      case "CREATE":
        response = await fetch(endpoint, {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify(op.payload),
        });
        break;
 
      case "UPDATE":
        response = await fetch(`${endpoint}/${op.entityId}`, {
          method: "PUT",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify(op.payload),
        });
        break;
 
      case "DELETE":
        response = await fetch(`${endpoint}/${op.entityId}`, {
          method: "DELETE",
        });
        break;
    }
 
    if (response.ok) {
      return true;
    }
 
    // 4xx errors should not be retried (bad request, not found, etc.)
    if (response.status >= 400 && response.status < 500) {
      console.warn(`Operation ${op.id} rejected by server: ${response.status}`);
      return true; // Remove from queue, it will never succeed
    }
 
    return false; // 5xx errors are retryable
  } catch (error) {
    console.error(`Network error for operation ${op.id}:`, error);
    return false;
  }
}
 
export async function processQueue(): Promise<{ processed: number; failed: number }> {
  const queue = getQueue();
  let processed = 0;
  let failed = 0;
 
  // Process in order to maintain causality
  for (const operation of queue) {
    const success = await executeOperation(operation);
 
    if (success) {
      dequeue(operation.id);
      processed++;
    } else {
      const canRetry = incrementRetry(operation.id);
      if (!canRetry) {
        failed++;
      }
      // Stop processing on failure to maintain order
      break;
    }
  }
 
  return { processed, failed };
}

A critical detail is that we stop processing on the first failure. This preserves operation ordering. If a create fails, you do not want a subsequent update to that same entity to be sent first.

Step 5: Implement Conflict Resolution

When two devices edit the same record offline, you need a strategy to resolve the conflict when both sync.

typescript
// src/sync/conflictResolver.ts
interface VersionedRecord {
  id: string;
  updatedAt: number;
  version: number;
  [key: string]: unknown;
}
 
type ConflictStrategy = "server-wins" | "client-wins" | "last-write-wins" | "merge";
 
export function resolveConflict<T extends VersionedRecord>(
  clientRecord: T,
  serverRecord: T,
  strategy: ConflictStrategy,
  mergeFields?: string[]
): T {
  switch (strategy) {
    case "server-wins":
      return serverRecord;
 
    case "client-wins":
      return clientRecord;
 
    case "last-write-wins":
      return clientRecord.updatedAt > serverRecord.updatedAt
        ? clientRecord
        : serverRecord;
 
    case "merge":
      return mergeRecords(clientRecord, serverRecord, mergeFields ?? []);
 
    default:
      return serverRecord;
  }
}
 
function mergeRecords<T extends VersionedRecord>(
  client: T,
  server: T,
  mergeFields: string[]
): T {
  // Start with the server version as the base
  const merged = { ...server };
 
  // Apply client changes only for specified merge fields
  for (const field of mergeFields) {
    if (field in client && client[field] !== server[field]) {
      // If the server has not changed this field from the original,
      // accept the client's change
      (merged as Record<string, unknown>)[field] = client[field];
    }
  }
 
  // Bump the version
  merged.version = Math.max(client.version, server.version) + 1;
  merged.updatedAt = Date.now();
 
  return merged;
}

For most applications, last-write-wins is the simplest and most pragmatic approach. Use merge when you have collaborative fields like a notes app where two users might edit different parts of the same document.

Step 6: Optimistic UI Updates

Optimistic updates make your app feel instant. You update the local state immediately and roll back only if the server rejects the change.

typescript
// src/hooks/useOptimisticMutation.ts
import { useState, useCallback } from "react";
import { useNetwork } from "../context/NetworkContext";
import { enqueue } from "../sync/operationQueue";
import { setItem, getItem } from "../storage/mmkv";
 
interface OptimisticOptions<T> {
  entity: string;
  onSuccess?: (data: T) => void;
  onError?: (error: Error, rollbackData: T | null) => void;
}
 
export function useOptimisticMutation<T extends { id: string }>(
  options: OptimisticOptions<T>
) {
  const { isConnected } = useNetwork();
  const [isLoading, setIsLoading] = useState(false);
 
  const mutate = useCallback(
    async (
      type: "CREATE" | "UPDATE" | "DELETE",
      entityId: string,
      payload: Partial<T>,
      currentData: T | null
    ) => {
      // Save rollback data
      const rollbackKey = `rollback_${options.entity}_${entityId}`;
      if (currentData) {
        setItem(rollbackKey, currentData);
      }
 
      // Optimistically update local storage
      const localKey = `${options.entity}_${entityId}`;
 
      if (type === "DELETE") {
        setItem(localKey, null);
      } else {
        const updated = { ...currentData, ...payload, id: entityId } as T;
        setItem(localKey, updated);
      }
 
      if (!isConnected) {
        // Queue for later sync
        enqueue(type, options.entity, entityId, payload as Record<string, unknown>);
        options.onSuccess?.(payload as T);
        return;
      }
 
      // Try to sync immediately
      setIsLoading(true);
      try {
        const response = await fetch(
          `https://api.yourapp.com/${options.entity}/${entityId}`,
          {
            method: type === "CREATE" ? "POST" : type === "UPDATE" ? "PUT" : "DELETE",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify(payload),
          }
        );
 
        if (!response.ok) throw new Error(`Server returned ${response.status}`);
 
        const data = await response.json();
        setItem(localKey, data);
        options.onSuccess?.(data);
      } catch (error) {
        // Rollback on failure
        const rollbackData = getItem<T>(rollbackKey);
        if (rollbackData) {
          setItem(localKey, rollbackData);
        }
        // Queue for retry
        enqueue(type, options.entity, entityId, payload as Record<string, unknown>);
        options.onError?.(error as Error, rollbackData);
      } finally {
        setIsLoading(false);
      }
    },
    [isConnected, options]
  );
 
  return { mutate, isLoading };
}

The key insight is the rollback pattern. Before any optimistic update, you snapshot the current state. If the network call fails, you restore that snapshot and queue the operation for retry.

Step 7: Background Sync

Use react-native-background-fetch to process the queue even when the app is not in the foreground.

typescript
// src/sync/backgroundSync.ts
import BackgroundFetch from "react-native-background-fetch";
import { processQueue } from "./syncEngine";
import { getQueue } from "./operationQueue";
 
export async function configureBackgroundSync(): Promise<void> {
  const status = await BackgroundFetch.configure(
    {
      minimumFetchInterval: 15, // minutes
      stopOnTerminate: false,
      startOnBoot: true,
      enableHeadless: true,
    },
    async (taskId: string) => {
      console.log("[BackgroundSync] Task started:", taskId);
 
      const queue = getQueue();
      if (queue.length > 0) {
        const result = await processQueue();
        console.log(
          `[BackgroundSync] Processed: ${result.processed}, Failed: ${result.failed}`
        );
      }
 
      BackgroundFetch.finish(taskId);
    },
    async (taskId: string) => {
      console.log("[BackgroundSync] Task timed out:", taskId);
      BackgroundFetch.finish(taskId);
    }
  );
 
  console.log("[BackgroundSync] Configured with status:", status);
}
 
// Headless task for when the app is terminated
export async function headlessTask(event: { taskId: string; timeout: boolean }): void {
  if (event.timeout) {
    BackgroundFetch.finish(event.taskId);
    return;
  }
 
  const queue = getQueue();
  if (queue.length > 0) {
    await processQueue();
  }
 
  BackgroundFetch.finish(event.taskId);
}

Register the headless task in your app entry point:

typescript
// index.js
import { AppRegistry } from "react-native";
import App from "./App";
import BackgroundFetch from "react-native-background-fetch";
import { headlessTask } from "./src/sync/backgroundSync";
 
AppRegistry.registerComponent("OfflineApp", () => App);
BackgroundFetch.registerHeadlessTask(headlessTask);

Step 8: Wire It All Together

Combine everything in your App component so the providers and sync engine initialize on startup.

typescript
// App.tsx
import React, { useEffect } from "react";
import { StatusBar, View, Text } from "react-native";
import { NetworkProvider, useNetwork } from "./src/context/NetworkContext";
import { configureBackgroundSync } from "./src/sync/backgroundSync";
import { processQueue } from "./src/sync/syncEngine";
import { getQueue } from "./src/sync/operationQueue";
 
function SyncManager({ children }: { children: React.ReactNode }) {
  const { isConnected } = useNetwork();
 
  useEffect(() => {
    if (isConnected) {
      const queue = getQueue();
      if (queue.length > 0) {
        processQueue().then((result) => {
          console.log(`Synced ${result.processed} operations`);
        });
      }
    }
  }, [isConnected]);
 
  return <>{children}</>;
}
 
function OfflineBanner() {
  const { isConnected } = useNetwork();
 
  if (isConnected) return null;
 
  return (
    <View style={{ backgroundColor: "#f59e0b", padding: 8, alignItems: "center" }}>
      <Text style={{ color: "#fff", fontWeight: "600" }}>
        You are offline. Changes will sync when you reconnect.
      </Text>
    </View>
  );
}
 
export default function App() {
  useEffect(() => {
    configureBackgroundSync();
  }, []);
 
  return (
    <NetworkProvider>
      <SyncManager>
        <StatusBar barStyle="dark-content" />
        <OfflineBanner />
        {/* Your app screens go here */}
      </SyncManager>
    </NetworkProvider>
  );
}

The Complete Architecture

Here is a summary of how all the pieces fit together:

  1. MMKV Storage serves as the single source of truth for local data
  2. NetworkContext broadcasts connectivity changes to every component
  3. Operation Queue persists pending mutations in MMKV, surviving app restarts
  4. Sync Engine processes the queue in order when connectivity returns
  5. Conflict Resolver handles cases where server data diverged from local data
  6. Optimistic Mutations update the UI immediately and roll back on failure
  7. Background Sync processes the queue even when the app is backgrounded

The data flow is straightforward: user actions write to local storage first, then either sync immediately (if online) or queue for later (if offline). When connectivity returns, the sync engine drains the queue operation by operation.

Next Steps

  • Add WatermelonDB for complex relational data that needs SQL-like querying
  • Implement delta sync to only fetch records that changed since the last sync timestamp
  • Add end-to-end encryption for sensitive data before it leaves the device
  • Build a sync status indicator showing queue depth and last successful sync time
  • Implement exponential backoff on the retry mechanism to avoid hammering the server

FAQ

What is the best local storage solution for offline-first React Native apps?

MMKV is the fastest option for key-value storage due to its synchronous C++ bridge, while WatermelonDB or Realm are better for complex relational data that needs querying.

How do you handle sync conflicts in offline-first apps?

Common strategies include last-write-wins using timestamps, server-wins for critical data, and merge-based resolution for collaborative fields where you merge non-conflicting changes.

Can React Native apps detect network status reliably?

Yes, using @react-native-community/netinfo you can detect connection type, whether the device is connected, and whether the internet is actually reachable via a head request.

What is optimistic UI and why use it in offline-first apps?

Optimistic UI immediately reflects user actions in the interface before server confirmation, making the app feel instant. If the server later rejects the change, you roll back to the previous state.

How do you handle background sync in React Native?

Use react-native-background-fetch to schedule periodic sync tasks that run even when the app is backgrounded, processing queued operations when connectivity is restored.

Collaboration

Need help with a project?

Let's Build It

I help startups and established companies design, build, and scale world-class digital products. From deep technical architecture to pixel-perfect UI — let's bring your vision to life.

SH

Article Author

Sadam Hussain

Senior Full Stack Developer

Senior Full Stack Developer with over 7 years of experience building React, Next.js, Node.js, TypeScript, and AI-powered web platforms.

Related Articles

How to Add Observability to a Node.js App with OpenTelemetry
Mar 21, 20265 min read
Node.js
OpenTelemetry
Observability

How to Add Observability to a Node.js App with OpenTelemetry

Learn how to instrument a Node.js app with OpenTelemetry for traces, metrics, and logs, and build a practical observability setup for production debugging.

How to Build a Backend-for-Frontend (BFF) with Next.js and Node.js
Mar 21, 20266 min read
Next.js
Node.js
BFF

How to Build a Backend-for-Frontend (BFF) with Next.js and Node.js

A practical guide to building a Backend-for-Frontend with Next.js and Node.js for API aggregation, auth handling, caching, and frontend-specific data shaping.

How I Structure CI/CD for Next.js, Docker, and GitHub Actions
Mar 21, 20265 min read
CI/CD
Next.js
Docker

How I Structure CI/CD for Next.js, Docker, and GitHub Actions

A practical CI/CD blueprint for Next.js apps using Docker and GitHub Actions, including testing, image builds, deployment stages, cache strategy, and release safety.