Building Offline-First Mobile Apps with React Native: A Practical Guide

calendar_today July 8, 2026
person info@softcrony.com
folder Mobile

In India, connectivity is not guaranteed. Mobile networks drop in highways, basement offices, rural areas, and even busy urban zones during peak hours.

If your app stops working when the internet goes down, you’re building for the best-case scenario — not for reality. This guide covers how to build React Native apps that work offline and sync reliably when connectivity returns.

What “Offline-First” Actually Means

Offline-first doesn’t mean your app works without internet forever. It means:

  • The app loads and functions without an active connection
  • User actions are queued locally when offline
  • Data syncs automatically when connectivity returns
  • The user is never blocked by a “No internet connection” error screen

The Core Pattern: Local First, Sync Second

Every offline-first app follows the same fundamental pattern:

  1. Read from local storage — never wait for the network to display data
  2. Write to local storage immediately — don’t wait for API confirmation
  3. Queue API calls when offline
  4. Sync the queue when connectivity returns
  5. Handle conflicts when server data differs from local data

Step 1 — Choose Your Local Storage Layer

React Native has several options for local data persistence:

Option Best For Complexity
AsyncStorage Simple key-value data, settings Low
MMKV Fast key-value, replacing AsyncStorage Low
SQLite (via op-sqlite) Structured data, complex queries Medium
WatermelonDB Large datasets, real-time sync High
Realm Complex sync, MongoDB backend High

For most business apps — logistics, field service, sales force — we recommend SQLite via op-sqlite for structured data and MMKV for settings and auth tokens.

Step 2 — Install Dependencies

# Local SQLite database
npm install @op-engineering/op-sqlite

# Fast key-value storage
npm install react-native-mmkv

# Network state monitoring
npm install @react-native-community/netinfo

# Background sync (optional)
npm install react-native-background-fetch

Step 3 — Monitor Network State

Create a custom hook to track connectivity:

// hooks/useNetworkStatus.js
import { useState, useEffect } from 'react';
import NetInfo from '@react-native-community/netinfo';

export function useNetworkStatus() {
  const [isOnline, setIsOnline] = useState(true);
  const [connectionType, setConnectionType] = useState(null);

  useEffect(() => {
    const unsubscribe = NetInfo.addEventListener(state => {
      setIsOnline(state.isConnected && state.isInternetReachable);
      setConnectionType(state.type);
    });

    return () => unsubscribe();
  }, []);

  return { isOnline, connectionType };
}

Step 4 — Build a Sync Queue

The sync queue is the heart of an offline-first app. It stores operations that need to be sent to the server:

// services/SyncQueue.js
import { MMKV } from 'react-native-mmkv';

const storage = new MMKV();
const QUEUE_KEY = 'sync_queue';

export const SyncQueue = {
  // Add an operation to the queue
  add(operation) {
    const queue = this.getAll();
    queue.push({
      id: Date.now().toString(),
      timestamp: new Date().toISOString(),
      ...operation,
    });
    storage.set(QUEUE_KEY, JSON.stringify(queue));
  },

  // Get all queued operations
  getAll() {
    const raw = storage.getString(QUEUE_KEY);
    return raw ? JSON.parse(raw) : [];
  },

  // Remove a processed operation
  remove(id) {
    const queue = this.getAll().filter(op => op.id !== id);
    storage.set(QUEUE_KEY, JSON.stringify(queue));
  },

  // Clear all operations (after full sync)
  clear() {
    storage.delete(QUEUE_KEY);
  },

  count() {
    return this.getAll().length;
  },
};

Step 5 — Process the Queue on Reconnection

// services/SyncService.js
import NetInfo from '@react-native-community/netinfo';
import { SyncQueue } from './SyncQueue';
import api from './api';

class SyncService {
  constructor() {
    this.isSyncing = false;
    this.setupNetworkListener();
  }

  setupNetworkListener() {
    NetInfo.addEventListener(state => {
      if (state.isConnected && state.isInternetReachable) {
        this.processQueue();
      }
    });
  }

  async processQueue() {
    if (this.isSyncing) return;

    const queue = SyncQueue.getAll();
    if (queue.length === 0) return;

    this.isSyncing = true;

    for (const operation of queue) {
      try {
        await this.executeOperation(operation);
        SyncQueue.remove(operation.id);
      } catch (error) {
        // Keep in queue if server error, remove if client error
        if (error.response?.status >= 400 && error.response?.status < 500) {
          SyncQueue.remove(operation.id);
        }
        console.error('Sync failed for operation:', operation.id, error);
      }
    }

    this.isSyncing = false;
  }

  async executeOperation(operation) {
    switch (operation.type) {
      case 'CREATE':
        return api.post(operation.endpoint, operation.data);
      case 'UPDATE':
        return api.put(`${operation.endpoint}/${operation.id}`, operation.data);
      case 'DELETE':
        return api.delete(`${operation.endpoint}/${operation.id}`);
      default:
        throw new Error(`Unknown operation type: ${operation.type}`);
    }
  }
}

export default new SyncService();

Step 6 — Use It in Your Components

// Example: Creating a delivery record offline-first
import { SyncQueue } from '../services/SyncQueue';
import { useNetworkStatus } from '../hooks/useNetworkStatus';
import { saveDeliveryLocally } from '../database/deliveries';

function CreateDeliveryForm() {
  const { isOnline } = useNetworkStatus();

  const handleSubmit = async (formData) => {
    // Always save locally first
    const localId = await saveDeliveryLocally(formData);

    if (isOnline) {
      // Send to server immediately if online
      try {
        await api.post('/deliveries', formData);
      } catch (error) {
        // If server fails, add to queue
        SyncQueue.add({
          type: 'CREATE',
          endpoint: '/deliveries',
          data: { ...formData, localId },
        });
      }
    } else {
      // Queue for later if offline
      SyncQueue.add({
        type: 'CREATE',
        endpoint: '/deliveries',
        data: { ...formData, localId },
      });
    }

    // Show success to user regardless — data is safe locally
    showSuccess('Delivery saved. Will sync when online.');
  };

  return (
    // your form JSX
  );
}

Step 7 — Show Sync Status to Users

// components/SyncStatusBar.js
import { useNetworkStatus } from '../hooks/useNetworkStatus';
import { SyncQueue } from '../services/SyncQueue';

function SyncStatusBar() {
  const { isOnline } = useNetworkStatus();
  const pendingCount = SyncQueue.count();

  if (isOnline && pendingCount === 0) return null;

  return (
    <View style={styles.bar}>
      {!isOnline ? (
        <Text>You are offline — changes will sync when connected</Text>
      ) : (
        <Text>Syncing {pendingCount} pending changes...</Text>
      )}
    </View>
  );
}

Handling Conflicts

When the same record is edited both locally and on the server while offline, you have a conflict. There are three standard strategies:

Last write wins — server timestamp always takes priority. Simple to implement, loses local changes. Acceptable for reference data.

Local wins — local changes always override server data. Good for user-owned data like personal settings or notes.

Manual merge — show the user both versions and let them decide. Best for critical business data like financial records or delivery confirmations.

For most field operations apps we recommend local wins for user-entered data and last write wins for server-pushed reference data (prices, product lists, routes).

Real-World Lessons from Our Projects

We’ve built offline-first apps for logistics companies, field sales teams, and healthcare workers across MP and CG. Here’s what we learned the hard way:

Test on real Indian networks, not WiFi. 4G in Jabalpur is not the same as 4G in the Vindhya hills. Use Chrome DevTools network throttling or real SIM cards on actual routes to test your sync behavior.

Show the offline state clearly. Users panic when they think their data is lost. A persistent “You are offline — your data is saved” banner prevents a lot of support calls.

Don’t sync everything at once. When the user reconnects after 3 days offline with 200 queued operations, sending 200 API calls simultaneously will crash your server and their connection. Process the queue in batches of 10–20.

Expire old queue items. Operations older than 7 days should be flagged for review, not blindly synced. Business rules change and a week-old operation may no longer be valid.

When to Use This Pattern

Offline-first is worth the extra complexity when:

  • Your users work in areas with unreliable connectivity
  • Data loss would have business or safety consequences
  • Your app is used for field operations (delivery, sales, inspection, healthcare)
  • You’re building for rural India, manufacturing plants, or basement offices

It’s overkill for apps where offline use is genuinely impossible (real-time trading, live video, navigation).

If you’re building a field operations app and need offline sync done right, talk to our mobile team at Softcrony.

Leave a comment