All writing
React Native

Building Offline First Apps in React Native - A Complete Guide

How to build React Native apps that keep working when the network drops

Networks fail. Users go into tunnels, board planes, leave the city. An app that breaks the moment the signal drops is a bad app.

This post covers how I build React Native apps that keep working offline.

Why offline first matters

Real conditions users hit:

Offline support isn’t a nice-to-have for any app people use on the move.

Building an offline-ready app

Five pieces: local storage, sync, caching, UX, background sync.

1. Local Data Storage

Two options I reach for: AsyncStorage for small key-value data, WatermelonDB for anything relational.

// Using AsyncStorage
import AsyncStorage from "@react-native-async-storage/async-storage";

const TodoStorage = {
  async saveItem(todo: Todo) {
    try {
      await AsyncStorage.setItem(`todo-${todo.id}`, JSON.stringify(todo));
    } catch (error) {
      console.error("Error saving todo:", error);
    }
  },

  async getItem(id: string) {
    try {
      const item = await AsyncStorage.getItem(`todo-${id}`);
      return item ? JSON.parse(item) : null;
    } catch (error) {
      console.error("Error getting todo:", error);
      return null;
    }
  },
};

// Using WatermelonDB
import { Model } from "@nozbe/watermelondb";
import { field, date, readonly } from "@nozbe/watermelondb/decorators";

class Todo extends Model {
  static table = "todos";

  @field("title") title;
  @field("completed") completed;
  @date("created_at") createdAt;
  @readonly @date("updated_at") updatedAt;
}

2. Sync queue

Queue writes while offline, flush them when the network comes back:

import NetInfo from "@react-native-community/netinfo";

class SyncService {
  private syncQueue: Array<() => Promise<void>> = [];
  private isSyncing = false;

  constructor() {
    // Listen for network changes
    NetInfo.addEventListener((state) => {
      if (state.isConnected && !this.isSyncing) {
        this.processSyncQueue();
      }
    });
  }

  addToSyncQueue(action: () => Promise<void>) {
    this.syncQueue.push(action);
    this.processSyncQueue();
  }

  private async processSyncQueue() {
    if (this.isSyncing || this.syncQueue.length === 0) return;

    const networkState = await NetInfo.fetch();
    if (!networkState.isConnected) return;

    this.isSyncing = true;

    try {
      while (this.syncQueue.length > 0) {
        const action = this.syncQueue.shift();
        if (action) {
          await action();
        }
      }
    } finally {
      this.isSyncing = false;
    }
  }
}

// Usage example:
const syncService = new SyncService();

function createTodo(todo: Todo) {
  // Save locally first
  await TodoStorage.saveItem(todo);

  // Queue sync with server
  syncService.addToSyncQueue(async () => {
    await api.todos.create(todo);
  });
}

3. API response caching

Persist your Apollo cache, or use an Axios cache adapter for REST:

import { ApolloClient, InMemoryCache } from "@apollo/client";
import { persistCache } from "apollo3-cache-persist";
import AsyncStorage from "@react-native-async-storage/async-storage";

const cache = new InMemoryCache();

// Setup persistent cache
await persistCache({
  cache,
  storage: AsyncStorage,
});

const client = new ApolloClient({
  cache,
  link: apolloLink,
  defaultOptions: {
    watchQuery: {
      fetchPolicy: "cache-and-network",
      nextFetchPolicy: "cache-first",
    },
  },
});

// For REST APIs with Axios
import axios from "axios";
import { setupCache } from "axios-cache-adapter";

const cache = setupCache({
  maxAge: 15 * 60 * 1000, // Cache for 15 minutes
  exclude: { query: false },
  storage: AsyncStorage,
});

const api = axios.create({
  adapter: cache.adapter,
});

4. Offline UX

Tell the user when they’re offline, and use optimistic updates so the app still feels responsive:

import React from "react";
import { View, Text, StyleSheet } from "react-native";
import NetInfo from "@react-native-community/netinfo";

export function OfflineNotice() {
  const [isOffline, setIsOffline] = React.useState(false);

  React.useEffect(() => {
    const unsubscribe = NetInfo.addEventListener((state) => {
      setIsOffline(!state.isConnected);
    });

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

  if (!isOffline) return null;

  return (
    <View style={styles.offlineContainer}>
      <Text style={styles.offlineText}>No Internet Connection</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  offlineContainer: {
    backgroundColor: "#b52424",
    height: 30,
    justifyContent: "center",
    alignItems: "center",
    flexDirection: "row",
    width: "100%",
    position: "absolute",
    top: 0,
  },
  offlineText: {
    color: "#fff",
  },
});

// Optimistic UI updates
function TodoList() {
  const [todos, setTodos] = React.useState([]);

  const addTodo = async (newTodo) => {
    // Optimistically update UI
    setTodos((prev) => [...prev, newTodo]);

    try {
      await api.todos.create(newTodo);
    } catch (error) {
      // Revert on error
      setTodos((prev) => prev.filter((todo) => todo.id !== newTodo.id));
      Alert.alert("Error", "Failed to create todo");
    }
  };

  return (
    <View>
      <OfflineNotice />
      {/* Todo list rendering */}
    </View>
  );
}

5. Background sync

react-native-background-fetch lets you flush the queue without the app being open:

import BackgroundFetch from "react-native-background-fetch";

class BackgroundSync {
  static async configure() {
    try {
      await BackgroundFetch.configure(
        {
          minimumFetchInterval: 15, // minutes
          stopOnTerminate: false,
          enableHeadless: true,
          startOnBoot: true,
        },
        async (taskId) => {
          // Sync your data here
          await syncService.processSyncQueue();
          BackgroundFetch.finish(taskId);
        }
      );
    } catch (error) {
      console.error("Background fetch setup failed:", error);
    }
  }
}

// Usage
BackgroundSync.configure();

Best Practices

  1. Always Save Locally First
async function saveData(data) {
  // Save to local storage first
  await localStorage.save(data);

  // Then try to sync with server
  try {
    await api.sync(data);
  } catch (error) {
    // Queue for later sync
    syncService.addToSyncQueue(() => api.sync(data));
  }
}
  1. Handle Conflicts Gracefully
function resolveConflict(localData, serverData) {
  // Timestamp-based resolution
  return localData.updatedAt > serverData.updatedAt ? localData : serverData;
}
  1. Implement Retry Logic
async function withRetry(fn, maxAttempts = 3) {
  let attempts = 0;
  while (attempts < maxAttempts) {
    try {
      return await fn();
    } catch (error) {
      attempts++;
      if (attempts === maxAttempts) throw error;
      await new Promise((r) => setTimeout(r, 1000 * attempts));
    }
  }
}

Testing offline behavior

Mock NetInfo and assert the UI responds:

import { render, act } from "@testing-library/react-native";
import NetInfo from "@react-native-community/netinfo";

jest.mock("@react-native-community/netinfo", () => ({
  addEventListener: jest.fn(),
  fetch: jest.fn(),
}));

test("shows offline notice when disconnected", async () => {
  NetInfo.fetch.mockResolvedValueOnce({ isConnected: false });

  const { getByText } = render(<OfflineNotice />);

  await act(async () => {
    // Trigger offline state
    const callback = NetInfo.addEventListener.mock.calls[0][0];
    callback({ isConnected: false });
  });

  expect(getByText("No Internet Connection")).toBeTruthy();
});

Summary

The rules I follow:

0 claps
If this was useful, let me know.
Complete Guide to React Native Internationalization (i18n) 10 VS Code Extensions I Actually Use