Mobile apps face unique challenges when it comes to maintaining a seamless user experience. Unlike web apps where we generally assume constant connectivity, mobile apps must gracefully handle situations where network access is limited, unstable, or completely unavailable.
The Mobile Dev Starter Pack has a huge focus on offline-first functionality. We settled on a robust mixture of Zustand for reactivity, AsyncStorage/MMKV/SQLite for persistence, and Supabase for our remote database. However, this article provides a comprehensive overview of going offline in React Native in general – including persistence, sync, conflict resolution, and more.
Why Offline-First Matters Even More in Mobile
Mobile users encounter connectivity issues far more frequently than desktop users. Whether they’re commuting through areas with poor coverage, dealing with spotty Wi-Fi, or trying to conserve data usage, your app needs to work reliably regardless of network conditions. Why offline-first is useful today:
- User Experience: Network requests that fail or timeout lead to frustrated users and poor app ratings. An offline-first approach ensures your app remains functional and responsive even without connectivity.
- Data Conservation: Mobile data plans can be expensive and limited. By implementing proper caching and offline storage strategies, you help users minimize their data usage while still accessing the content they need.
- Performance: Even when users have network access, loading data from local storage is significantly faster than making network requests. This leads to a more responsive application and better user experience.
- Battery Life: Constant network requests drain mobile device batteries. Smart offline strategies can help reduce unnecessary network calls and preserve battery life.
React Native Offline Considerations
Data Storage Strategies
React Native developers have access to several local storage solutions, each optimized for different use cases. Understanding the performance characteristics and trade-offs of each option is crucial for building efficient offline-capable applications. Let’s explore these approaches and their performance implications. The main two are AsyncStorage and SQLite.
(MMKV is great, but has storage limitations.)
AsyncStorage: The Basic Building Block
AsyncStorage is React Native’s simple key-value storage system, similar to localStorage in web browsers. While it’s not suitable for complex data structures, it’s perfect for storing small amounts of data like user preferences or authentication tokens.
Here’s a practical example of using AsyncStorage:
import AsyncStorage from '@react-native-async-storage/async-storage';
// Storing data
const saveUserPreferences = async (preferences) => {
try {
await AsyncStorage.setItem(
'userPreferences',
JSON.stringify(preferences)
);
} catch (error) {
console.error('Error saving preferences:', error);
}
};
// Retrieving data
const loadUserPreferences = async () => {
try {
const savedPreferences = await AsyncStorage.getItem('userPreferences');
return savedPreferences ? JSON.parse(savedPreferences) : null;
} catch (error) {
console.error('Error loading preferences:', error);
return null;
}
};
Let’s look at AsyncStorage’s performance characteristics and limitations:
Performance Profile:
- Read operations: ~5-10ms for small items (<1KB)
- Write operations: ~10-15ms for small items
- Scales linearly with data size
- Performance degrades significantly with items larger than 100KB
Key Limitations:
- Not suitable for storing large amounts of data (recommended limit: 6MB total on Android)
- Doesn’t support complex queries or indexing
- All data must be serialized to strings (can become expensive!)
- No support for concurrent transactions
For perspective, storing and retrieving a 1MB JSON object with AsyncStorage can take up to 200-300ms, while the same operation with SQLite might take only 50-70ms. This difference becomes more pronounced as data size increases.
SQLite: When You Need Relational Data
For more complex data requirements, SQLite provides a full-featured relational database that runs directly on the device. It significantly outperforms AsyncStorage for structured data and large datasets:
Performance Characteristics:
- Bulk insertions: ~1000 records/second
- Index-based queries: 1-5ms for simple queries
- Complex JOIN operations: 10-50ms depending on table sizes
- Support for transactions and concurrent operations
- Efficient handling of large datasets (100MB+)
Key Use Cases:
- Storing large amounts of structured data
- Performing complex queries with multiple conditions
- Maintaining data relationships
- Handling concurrent operations
Alternative Storage Options: While SQLite is often the go-to choice for complex data storage, several other solutions are worth considering:
- Realm: Offers excellent performance (2-3x faster than SQLite for most operations) and built-in synchronization, but comes with a steeper learning curve
- PouchDB: Provides robust sync capabilities with CouchDB and works well for document-based data
- WatermelonDB: Optimized for React Native with efficient querying and relationship handling
Here’s an example of setting up and using SQLite in React Native:
import SQLite from 'react-native-sqlite-storage';
const db = SQLite.openDatabase(
{
name: 'MainDB',
location: 'default',
},
() => {
console.log('Database connected!');
},
error => {
console.error('Database error:', error);
}
);
// Creating a table
const createTable = () => {
db.transaction(tx => {
tx.executeSql(
'CREATE TABLE IF NOT EXISTS Posts (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, content TEXT, created_at INTEGER)',
[],
(tx, results) => {
console.log('Table created successfully');
},
error => {
console.error('Error creating table:', error);
}
);
});
};
// Inserting data
const savePost = (title, content) => {
db.transaction(tx => {
tx.executeSql(
'INSERT INTO Posts (title, content, created_at) VALUES (?, ?, ?)',
[title, content, Date.now()],
(tx, results) => {
console.log('Post saved with id:', results.insertId);
},
error => {
console.error('Error saving post:', error);
}
);
});
};
// Handling SQLite Migrations
const SCHEMA_VERSION = 1;
const performMigrations = async () => {
try {
// Get current schema version from AsyncStorage
const currentVersion = await AsyncStorage.getItem('SCHEMA_VERSION');
const version = parseInt(currentVersion) || 0;
if (version < SCHEMA_VERSION) {
// Execute migrations in order
if (version < 1) {
await migrateToV1();
}
// Add more version checks as needed
// Update schema version
await AsyncStorage.setItem('SCHEMA_VERSION', SCHEMA_VERSION.toString());
}
} catch (error) {
console.error('Migration failed:', error);
}
};
const migrateToV1 = () => {
return new Promise((resolve, reject) => {
db.transaction(tx => {
// Example migration: Add a new column to Posts table
tx.executeSql(
'ALTER TABLE Posts ADD COLUMN status TEXT DEFAULT "draft"',
[],
(tx, results) => {
console.log('Migration to v1 completed');
resolve();
},
error => {
console.error('Migration to v1 failed:', error);
reject(error);
}
);
});
});
};
/*
Migration Best Practices:
1. Always test migrations with real data
2. Back up data before migration when possible
3. Make migrations idempotent
4. Handle migration failures gracefully
5. Consider adding a rollback mechanism for critical migrations
*/
Network Strategies for Offline-First Applications
Understanding how to handle network connectivity in React Native requires thinking about networking in a fundamentally different way than we do for web applications. Instead of assuming constant connectivity and handling offline states as errors, we need to treat offline capability as a core feature of our application architecture.
Network Status Detection and Management
The first step in building offline-capable applications is implementing robust network status detection. React Native provides this capability through the @react-native-community/netinfo package. Let’s explore how to implement this effectively:
import NetInfo from '@react-native-community/netinfo';
// Create a network status manager to centralize network handling
class NetworkManager {
constructor() {
this.listeners = new Set();
this.isConnected = true;
// Initialize network monitoring
NetInfo.addEventListener(state => {
const wasConnected = this.isConnected;
this.isConnected = state.isConnected && state.isInternetReachable;
// Only notify if connection status actually changed
if (wasConnected !== this.isConnected) {
this.notifyListeners();
}
// If we're back online, trigger sync
if (!wasConnected && this.isConnected) {
this.handleReconnection();
}
});
}
// Allow components to subscribe to network changes
addListener(callback) {
this.listeners.add(callback);
// Immediately notify of current status
callback(this.isConnected);
return () => this.listeners.delete(callback);
}
notifyListeners() {
this.listeners.forEach(listener => listener(this.isConnected));
}
async handleReconnection() {
// Trigger background sync when connection is restored
await this.synchronizeOfflineActions();
}
async synchronizeOfflineActions() {
// We'll implement this in the next section
}
}
// Create a singleton instance
export const networkManager = new NetworkManager();
This network manager does more than simply detect connectivity—it provides a foundation for handling network state changes intelligently. By centralizing network status management, we can respond to connectivity changes consistently throughout our application.
Intelligent Request Handling with Axios
With our network status detection in place, we can build a sophisticated request handling system using Axios. This system will automatically handle offline scenarios and retry failed requests when appropriate:
import axios from 'axios';
import AsyncStorage from '@react-native-async-storage/async-storage';
// Create an axios instance with custom configuration
const api = axios.create({
baseURL: 'https://api.yourapp.com',
timeout: 10000,
});
// Queue to store requests made while offline
const requestQueue = {
async addToQueue(request) {
try {
const queue = await this.getQueue();
queue.push(request);
await AsyncStorage.setItem('requestQueue', JSON.stringify(queue));
} catch (error) {
console.error('Error adding to queue:', error);
}
},
async getQueue() {
try {
const queue = await AsyncStorage.getItem('requestQueue');
return queue ? JSON.parse(queue) : [];
} catch (error) {
console.error('Error getting queue:', error);
return [];
}
},
async clearQueue() {
await AsyncStorage.removeItem('requestQueue');
}
};
// Add request interceptor to handle offline scenarios
api.interceptors.request.use(async config => {
// Check if we're online
const networkState = await NetInfo.fetch();
const isConnected = networkState.isConnected && networkState.isInternetReachable;
if (!isConnected) {
// If the request is marked as requiring immediate execution, throw an error
if (config.requiresConnectivity) {
throw new Error('No network connectivity');
}
// Otherwise, queue the request for later
await requestQueue.addToQueue({
url: config.url,
method: config.method,
data: config.data,
headers: config.headers,
timestamp: Date.now(),
});
// Throw a special error that we can handle in our UI
const error = new Error('Request queued');
error.isQueued = true;
throw error;
}
return config;
}, error => {
return Promise.reject(error);
});
// Implement queue processing when back online
NetworkManager.prototype.synchronizeOfflineActions = async function() {
const queue = await requestQueue.getQueue();
if (queue.length === 0) return;
console.log(`Processing ${queue.length} queued requests`);
const results = await Promise.allSettled(
queue.map(async request => {
try {
const response = await api({
...request,
// Add metadata to indicate this is a retry
headers: {
...request.headers,
'X-Retry-Count': '1',
}
});
return response;
} catch (error) {
return Promise.reject(error);
}
})
);
// Clear the queue after processing
await requestQueue.clearQueue();
// Handle any failed requests
const failedRequests = results.filter(r => r.status === 'rejected');
if (failedRequests.length > 0) {
console.error(`${failedRequests.length} requests failed to process`);
// Implement your error handling strategy here
}
};
This implementation provides several sophisticated features:
- Automatic request queueing when offline
- Persistent storage of queued requests
- Automatic retry when connectivity is restored
- Support for priority requests that require immediate connectivity
- Metadata tracking for retried requests
Optimistic Updates and Conflict Resolution
When building offline-first applications, optimistic updates can significantly improve the perceived performance of your app. However, they require careful handling of potential conflicts. Here’s how to implement this pattern safely:
// Define a data manager that handles both local and remote state
class DataManager {
constructor() {
this.localDb = SQLite.openDatabase({
name: 'MainDB',
location: 'default',
});
this.pendingChanges = new Map();
}
// Implement optimistic updates with version tracking
async updateRecord(tableName, recordId, changes) {
// First, get the current version of the record
const currentRecord = await this.getRecord(tableName, recordId);
const newVersion = (currentRecord?.version || 0) + 1;
// Store the pending change with its version
this.pendingChanges.set(recordId, {
changes,
version: newVersion,
timestamp: Date.now()
});
// Immediately update local database
await this.updateLocalRecord(tableName, recordId, {
...changes,
version: newVersion,
pending: true
});
try {
// Attempt to sync with server
const serverResponse = await api.post(`/sync/${tableName}/${recordId}`, {
changes,
baseVersion: currentRecord?.version,
newVersion
});
// If successful, clear pending change and update local record
this.pendingChanges.delete(recordId);
await this.updateLocalRecord(tableName, recordId, {
...serverResponse.data,
pending: false
});
} catch (error) {
if (error.isQueued) {
// Request was queued for later due to offline state
// Nothing more to do as local state is already updated
return;
}
if (error.response?.status === 409) {
// Conflict detected - server has a newer version
await this.handleConflict(tableName, recordId, changes, error.response.data);
}
}
}
async handleConflict(tableName, recordId, localChanges, serverData) {
// Get the full history of pending changes for this record
const pendingChange = this.pendingChanges.get(recordId);
// Implement a three-way merge strategy
const resolvedData = await this.mergeChanges(
serverData,
localChanges,
pendingChange
);
// Update local record with resolved data
await this.updateLocalRecord(tableName, recordId, {
...resolvedData,
version: serverData.version,
pending: false
});
// Clear pending change as it's now resolved
this.pendingChanges.delete(recordId);
// Notify user if manual intervention is needed
if (resolvedData.needsReview) {
this.notifyConflict(recordId, resolvedData);
}
}
async mergeChanges(serverData, localChanges, pendingChange) {
// Implement different merge strategies based on data type
const strategies = {
// Strategy for text fields: Use most recent change
text: (server, local, base) => {
const serverTime = new Date(server.timestamp);
const localTime = new Date(local.timestamp);
return serverTime > localTime ? server.value : local.value;
},
// Strategy for numerical fields: Handle concurrent updates
number: (server, local, base) => {
// If both made relative changes (like incrementing a counter),
// we need to preserve both operations
const serverDelta = server.value - base.value;
const localDelta = local.value - base.value;
return base.value + serverDelta + localDelta;
},
// Strategy for arrays: Smart merge based on operation type
array: (server, local, base) => {
// Implementation depends on your specific needs
// Example: Merge unique items from both sources
return [...new Set([...server.value, ...local.value])];
}
};
// Apply appropriate merge strategy for each field
const resolved = {};
for (const [field, value] of Object.entries(localChanges)) {
const strategy = strategies[this.getFieldType(field)] || strategies.text;
resolved[field] = strategy(
{ value: serverData[field], timestamp: serverData.timestamp },
{ value, timestamp: pendingChange.timestamp },
{ value: pendingChange.originalValue }
);
}
return resolved;
}
}
// Example usage in a React component
function TodoItem({ id, title, onUpdate }) {
const [isEditing, setIsEditing] = useState(false);
const [pendingUpdate, setPendingUpdate] = useState(false);
const handleUpdate = async (newTitle) => {
setIsEditing(false);
setPendingUpdate(true);
try {
await dataManager.updateRecord('todos', id, { title: newTitle });
} catch (error) {
if (!error.isQueued) {
// Show error message to user
Alert.alert('Update failed', error.message);
}
} finally {
setPendingUpdate(false);
}
};
return (
<View style={styles.todoItem}>
{isEditing ? (
<TodoEditForm
initialValue={title}
onSubmit={handleUpdate}
onCancel={() => setIsEditing(false)}
/>
) : (
<>
<Text style={[
styles.todoText,
pendingUpdate && styles.pendingUpdate
]}>
{title}
</Text>
<TouchableOpacity
onPress={() => setIsEditing(true)}
disabled={pendingUpdate}
>
<Text>Edit</Text>
</TouchableOpacity>
</>
)}
</View>
);
}
This implementation provides several sophisticated features for handling offline data updates:
- Version Tracking: Each record maintains a version number that increments with every change, allowing us to detect concurrent modifications.
- Optimistic Updates: Changes are immediately reflected in the local database and UI, while being queued for server synchronization.
- Conflict Detection: When syncing with the server, version numbers are compared to detect if any concurrent modifications have occurred.
- Smart Merge Strategies: Different types of data (text, numbers, arrays) can have different merge strategies when conflicts occur.
- Pending State Tracking: The UI reflects when changes are pending synchronization, providing visual feedback to users.
Testing Offline Functionality
Testing offline capabilities requires a systematic approach to ensure your app handles all possible scenarios gracefully. Network connectivity isn’t binary—it exists on a spectrum from fully connected to completely offline, with many states in between. Let’s explore how to comprehensively test these scenarios.
Unit Testing Core Offline Components
First, let’s set up a testing environment that can simulate various network conditions. We’ll use Jest and React Native Testing Library, along with some custom utilities to mock network behavior:
// __tests__/utils/networkMock.js
export class NetworkMockManager {
constructor() {
this.isConnected = true;
this.listeners = new Set();
}
setConnectionStatus(status) {
this.isConnected = status;
this.notifyListeners();
}
addListener(callback) {
this.listeners.add(callback);
return () => this.listeners.delete(callback);
}
notifyListeners() {
this.listeners.forEach(listener =>
listener({
isConnected: this.isConnected,
isInternetReachable: this.isConnected
})
);
}
}
// Mock NetInfo for all tests
jest.mock('@react-native-community/netinfo', () => {
const mockManager = new NetworkMockManager();
return {
addEventListener: jest.fn(callback => mockManager.addListener(callback)),
fetch: jest.fn(() => Promise.resolve({
isConnected: mockManager.isConnected,
isInternetReachable: mockManager.isConnected
})),
// Expose mock manager for test control
__mockManager: mockManager
};
});
// __tests__/DataManager.test.js
import { DataManager } from '../src/services/DataManager';
import NetInfo from '@react-native-community/netinfo';
describe('DataManager', () => {
let dataManager;
let networkManager;
beforeEach(() => {
dataManager = new DataManager();
networkManager = NetInfo.__mockManager;
});
test('should queue updates when offline', async () => {
// Simulate offline state
networkManager.setConnectionStatus(false);
const update = { title: 'New Title' };
await dataManager.updateRecord('todos', '123', update);
// Verify update is in queue
const queue = await dataManager.getQueuedUpdates();
expect(queue).toContainEqual(expect.objectContaining({
recordId: '123',
changes: update
}));
});
test('should handle version conflicts correctly', async () => {
// Simulate a conflict scenario
networkManager.setConnectionStatus(true);
// Set up initial state
const initialData = { title: 'Original', version: 1 };
await dataManager.updateLocalRecord('todos', '123', initialData);
// Simulate server having a newer version
const serverResponse = {
status: 409,
data: {
title: 'Server Version',
version: 2,
timestamp: Date.now()
}
};
// Mock API to return conflict
jest.spyOn(global, 'fetch').mockImplementationOnce(() =>
Promise.reject({ response: serverResponse })
);
// Attempt update
const update = { title: 'Local Change' };
await dataManager.updateRecord('todos', '123', update);
// Verify conflict resolution
const finalRecord = await dataManager.getRecord('todos', '123');
expect(finalRecord.version).toBe(2);
expect(finalRecord.pending).toBe(false);
});
});
Integration Testing with Network Scenarios
Integration tests help ensure different parts of your offline system work together correctly. Here’s how to test complete offline workflows:
// __tests__/integration/OfflineWorkflow.test.js
import { render, fireEvent, waitFor } from '@testing-library/react-native';
import { TodoList } from '../src/components/TodoList';
import NetInfo from '@react-native-community/netinfo';
describe('TodoList Offline Workflow', () => {
let networkManager;
beforeEach(() => {
networkManager = NetInfo.__mockManager;
});
test('should handle offline-online transition with pending changes', async () => {
// Start online
networkManager.setConnectionStatus(true);
const { getByText, getByTestId } = render(<TodoList />);
// Create a new todo while online
fireEvent.press(getByText('Add Todo'));
fireEvent.changeText(getByTestId('todo-input'), 'Buy groceries');
fireEvent.press(getByText('Save'));
// Verify todo is added
await waitFor(() => {
expect(getByText('Buy groceries')).toBeTruthy();
});
// Go offline and make changes
networkManager.setConnectionStatus(false);
// Edit the todo while offline
fireEvent.press(getByText('Edit'));
fireEvent.changeText(getByTestId('todo-input'), 'Buy groceries and milk');
fireEvent.press(getByText('Save'));
// Verify optimistic update
expect(getByText('Buy groceries and milk')).toBeTruthy();
expect(getByTestId('sync-pending-indicator')).toBeTruthy();
// Go back online
networkManager.setConnectionStatus(true);
// Verify sync completion
await waitFor(() => {
expect(getByTestId('sync-pending-indicator')).toBeFalsy();
});
});
});
Manual Testing Procedures
While automated tests are crucial, certain scenarios are best verified through manual testing. Here’s a systematic approach for manual testing of offline functionality:
- Network State Transitions:
- Test rapid online/offline transitions
- Test poor connectivity scenarios (slow, intermittent)
- Test airplane mode toggles
- Test background/foreground transitions during sync
- Data Synchronization:
- Make multiple offline changes and verify sync order
- Test large data set synchronization
- Verify data integrity after sync
- Check conflict resolution UI flows
- User Experience:
- Verify loading states and progress indicators
- Test error messages and recovery flows
- Check offline mode indicators
- Verify data access while offline
- Edge Cases:
- Test device storage limits
- Verify behavior with corrupt local data
- Test sync with very old offline changes
- Check timeout handling
Common Edge Cases to Watch For
When testing offline functionality, pay special attention to these often-overlooked scenarios:
- Storage Quota Exceeded:
// Test handling of storage limits
test('should handle storage quota exceeded', async () => {
// Fill storage to near capacity
const largeData = new Array(1000000).fill('x').join('');
await AsyncStorage.setItem('testData', largeData);
// Attempt to queue a new update
try {
await dataManager.queueUpdate('todos', '123', { title: 'New Todo' });
// Should handle gracefully
expect(await dataManager.getErrorState()).toBe('STORAGE_WARNING');
} catch (error) {
fail('Should handle storage limit gracefully');
}
});
- Partial Sync Failures:
test('should handle partial sync failures', async () => {
const updates = [
{ id: '1', changes: { title: 'First' } },
{ id: '2', changes: { title: 'Second' } },
{ id: '3', changes: { title: 'Third' } }
];
// Mock second update to fail
mockApi.sync.mockImplementation(async (id) => {
if (id === '2') throw new Error('Sync failed');
return { success: true };
});
await dataManager.syncMultiple(updates);
// Verify successful updates completed
expect(await dataManager.getRecord('1')).toBeDefined();
expect(await dataManager.getRecord('3')).toBeDefined();
// Verify failed update still queued
const pending = await dataManager.getPendingUpdates();
expect(pending).toContainEqual(expect.objectContaining({
id: '2'
}));
});
You need to think about offline-first differently
Building truly offline-capable applications requires more than just handling the absence of network connectivity—it demands a fundamental shift in how we think about application architecture. Throughout this guide, we’ve explored the various components that make up a robust offline-first application, from data storage to synchronization strategies.
The success of an offline-first application rests on several foundational principles that should guide your development process. The most crucial principle is treating offline capability as a core feature rather than an error state. This means designing your data flow, user interface, and architecture with offline scenarios in mind from the very beginning.
Consider the difference between an online-first and offline-first approach in creating a new todo item:
// Online-first approach (problematic)
async function createTodo(todo) {
try {
const response = await api.post('/todos', todo);
updateLocalState(response.data);
} catch (error) {
// Show error message
showError('Failed to create todo');
}
}
// Offline-first approach (recommended)
async function createTodo(todo) {
// Generate a temporary ID for immediate local use
const tempId = generateLocalId();
// Immediately update local state
const newTodo = {
...todo,
id: tempId,
status: 'pending',
createdAt: Date.now()
};
// Update local state immediately
updateLocalState(newTodo);
// Attempt to sync with server
try {
const response = await api.post('/todos', todo);
// Update local state with server response
updateLocalState({
...response.data,
status: 'synced'
});
} catch (error) {
if (error.isOffline) {
// Queue for later sync
queueSync({
type: 'CREATE_TODO',
data: todo,
tempId,
timestamp: Date.now()
});
} else {
// Handle other errors while maintaining local state
updateLocalState({
...newTodo,
status: 'error',
error: error.message
});
}
}
}