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:
- Patchy network coverage
- Subway commutes
- Data turned off to save battery
- Airplane mode
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
- 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));
}
}
- Handle Conflicts Gracefully
function resolveConflict(localData, serverData) {
// Timestamp-based resolution
return localData.updatedAt > serverData.updatedAt ? localData : serverData;
}
- 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:
- Save locally first, sync after
- Queue writes when offline, flush on reconnect
- Show the offline state in the UI
- Resolve conflicts with timestamps or a clear policy
- Test with the network actually off, not just mocked