Implementing Offline-First Architecture
1. Understanding Offline-First Principles
| Principle | Description | Implementation |
|---|---|---|
| Data Synchronization | Ensuring data consistency between offline and online states | Use libraries like Redux Offline or WatermelonDB |
| Caching Strategy | Storing data locally to reduce dependency on network | Implement AsyncStorage or SQLite for local storage |
| Conflict Resolution | Handling data conflicts when re-syncing offline changes | Use timestamps or version numbers to resolve conflicts |
| User Experience | Providing feedback to users when offline | Display offline indicators and retry options |
Example: Detecting Network Status
import { NetInfo } from 'react-native';
const handleNetworkChange = (isConnected) => {
if (!isConnected) {
console.log('Offline: Data will be cached locally');
} else {
console.log('Online: Syncing data with server');
}
};
NetInfo.addEventListener('connectionChange', handleNetworkChange);
Note: Offline-first architecture ensures that users can continue to interact with the app even when there is no internet connection.
2. Implementing Local Data Caching
Example: Using AsyncStorage for Local Caching
import AsyncStorage from '@react-native-async-storage/async-storage';
const cacheKey = 'userProfile';
const userData = {
id: 123,
name: 'John Doe',
email: 'john@example.com'
};
// Save to cache
const saveToCache = async () => {
try {
await AsyncStorage.setItem(cacheKey, JSON.stringify(userData));
console.log('Data cached successfully');
} catch (error) {
console.error('Failed to cache data', error);
}
};
// Retrieve from cache
const getFromCache = async () => {
try {
const cachedData = await AsyncStorage.getItem(cacheKey);
if (cachedData) {
return JSON.parse(cachedData);
}
return null;
} catch (error) {
console.error('Failed to retrieve cached data', error);
return null;
}
};
| Method | Description | Notes |
|---|---|---|
| AsyncStorage.setItem(key, value) | Sets a key-value pair in storage | Value must be stringified JSON |
| AsyncStorage.getItem(key) | Retrieves a value by key | Returns string or null |
| AsyncStorage.removeItem(key) | Removes a key-value pair | Does not throw if key doesn't exist |
| AsyncStorage.clear() | Removes all keys | Use carefully in production |
Note: AsyncStorage is not suitable for large data sets or sensitive information. For larger data, consider using SQLite or Realm.
Example: Caching with Expiry
const CACHE_EXPIRY = 5 * 60 * 1000; // 5 minutes
const setCachedWithExpiry = async (key, data) => {
const item = {
value: data,
timestamp: Date.now()
};
await AsyncStorage.setItem(key, JSON.stringify(item));
};
const getCachedWithExpiry = async (key) => {
const cachedItem = await AsyncStorage.getItem(key);
if (!cachedItem) return null;
const { value, timestamp } = JSON.parse(cachedItem);
if (Date.now() - timestamp > CACHE_EXPIRY) {
await AsyncStorage.removeItem(key);
return null;
}
return value;
};
Best Practices for Local Caching
- Use
AsyncStoragefor simple key-value caching - Implement cache expiration to prevent stale data
- Avoid storing sensitive user data in
AsyncStorage - Consider using libraries like
react-native-mmkvfor better performance - Implement cache invalidation when data changes on the server
3. Using NetInfo for Connectivity Detection
Example: Check Network Status
import { NetInfo } from 'react-native';
const handleConnectivityChange = (isConnected) => {
console.log('Network status:', isConnected ? 'online' : 'offline');
};
NetInfo.addEventListener('connectionChange', handleConnectivityChange);
// Check current status
NetInfo.fetch().then((state) => {
console.log('Current network type:', state.type);
console.log('Is connected:', state.isConnected);
});
| Property | Description | Values |
|---|---|---|
| isConnected | Indicates if the device is connected to a network | true / false |
| type | Network type (e.g., WiFi, cellular) | "none", "wifi", "cellular", "unknown" |
| isConnectionExpensive | Indicates if the connection is expensive (e.g., cellular) | true / false |
| isInternetReachable | Checks if the device can reach the internet | true / false |
Note: On iOS,
isInternetReachable may return false even when the device is connected to a network if it cannot reach the internet.
Warning:
NetInfo is deprecated in favor of react-native-netinfo. Use the new package for better support and features.
Example: Using react-native-netinfo
import NetInfo from '@react-native-community/netinfo';
const handleConnectivityChange = (state) => {
console.log('Network type:', state.type);
console.log('Is connected:', state.isConnected);
};
NetInfo.addEventListener(handleConnectivityChange);
// Fetch current network info
NetInfo.fetch().then((state) => {
console.log('Current network info:', state);
});
4. Implementing Request Queuing
Example: Basic Request Queue Implementation
class RequestQueue {
constructor() {
this.queue = [];
this.isProcessing = false;
}
enqueue(request) {
this.queue.push(request);
if (!this.isProcessing) {
this.processQueue();
}
}
async processQueue() {
this.isProcessing = true;
while (this.queue.length > 0) {
const request = this.queue.shift();
try {
await this.executeRequest(request);
} catch (error) {
console.error('Failed to process request:', error);
}
}
this.isProcessing = false;
}
async executeRequest(request) {
const { url, method, data } = request;
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return await response.json();
}
}
const queue = new RequestQueue();
queue.enqueue({
url: 'https://api.example.com/users',
method: 'POST',
data: { name: 'John Doe' },
});
| Property | Description | Default |
|---|---|---|
| queue | Array storing pending requests | [] |
| isProcessing | Flag indicating if queue is being processed | false |
Note: The queue ensures that requests are processed sequentially and only when network connectivity is restored.
Warning: Ensure that queue size is managed to prevent memory overflow in long-running applications.
5. Handling Sync Conflicts
Note: Sync conflict resolution is critical in offline-first apps to maintain data consistency when users go online and sync changes.
| Conflict Type | Description | Resolution Strategy | Example |
|---|---|---|---|
| Last Write Wins (LWW) | Most recent write overwrites previous values | Automatically apply latest change | const latest = getLatestChange();\napplyChange(latest); |
| Merge Strategies | Combine changes from multiple sources | Manual or automated merge logic | const merged = mergeChanges(local, remote);\nupdateStore(merged); |
| User Decision | Require user to choose which version to keep | UI prompt for conflict resolution | showConflictDialog(local, remote);\nresolveWithUserChoice(); |
Example: Conflict Detection and Resolution
import { AsyncStorage } from 'react-native';
// Detect conflict based on timestamps or version numbers
const detectConflict = (local, remote) => {
return local.version !== remote.version;
};
// Resolve using last-write-wins strategy
const resolveConflict = (local, remote) => {
const latest = new Date(local.timestamp) > new Date(remote.timestamp) ? local : remote;
return latest;
};
// Save resolved data back to storage
const saveResolved = async (resolved) => {
await AsyncStorage.setItem('data', JSON.stringify(resolved));
};
Conflict Resolution Approaches
- Automatic: Apply latest changes without user input
- Manual: Prompt user to select desired version
- Merge: Combine changes intelligently (e.g., for non-overlapping fields)
Warning: Always log conflict events for debugging and analytics to understand how often conflicts occur in your app.
6. Using Background Sync
Background sync allows React Native apps to perform network operations even when the app is not in the foreground. This is particularly useful for offline-first architectures where data needs to be synchronized when connectivity is restored.
| Feature | Description | Support |
|---|---|---|
| Background Sync API | Enables scheduling tasks to run in the background when network connectivity is restored. | NEW Available in React Native 0.72+ |
| Task Scheduling | Allows scheduling of tasks to run at specific intervals or on network changes. | Platform-specific support |
Example: Scheduling Background Sync
import { BackgroundFetch } from 'react-native-background-fetch';
const configureBackgroundFetch = async () => {
await BackgroundFetch.configure({
stopOnTerminate: false,
startOnBoot: true,
enableHeadless: true,
}, async (taskId) => {
console.log('[BackgroundFetch] Task executed:', taskId);
// Perform sync logic here
await performSync();
BackgroundFetch.finish(taskId);
});
};
const performSync = async () => {
// Sync logic here
console.log('Syncing data...');
};
configureBackgroundFetch();
Note: Background sync is not available on all platforms. iOS and Android have different capabilities and configurations required.
Warning: Ensure proper handling of network errors and retries to maintain data consistency.
Key Considerations
- Background sync is not a replacement for real-time data synchronization.
- Implement proper error handling and retry mechanisms.
- Use platform-specific configurations for optimal performance.
7. Implementing Optimistic Updates
Optimistic updates improve perceived performance by immediately updating the UI before the server response is received. This technique is particularly useful in mobile apps where network latency can be significant.
| Property | Description | Usage |
|---|---|---|
| optimisticUpdate | Flag indicating whether to apply optimistic updates | true or false |
| updateFunction | Function to apply local state changes | Callback function that modifies the UI state |
| rollbackFunction | Function to revert local changes if server fails | Callback function that restores previous state |
Example: Optimistic Update in a Todo List
const [todos, setTodos] = useState([]);\n\nconst addTodoOptimistically = (newTodo) => {\n // Optimistically update UI\n const optimisticTodos = [...todos, { ...newTodo, id: Date.now() }];\n setTodos(optimisticTodos);\n\n // Simulate API call\n setTimeout(() => {\n fetch('/api/todos', {\n method: 'POST',\n body: JSON.stringify(newTodo)\n })\n .then(response => response.json())\n .then(savedTodo => {\n // If successful, update with real data\n setTodos(todos.map(todo => \n todo.id === newTodo.id ? savedTodo : todo\n ));\n })\n .catch(() => {\n // If failed, rollback to original state\n setTodos(todos);\n });\n }, 1000);\n};
Note: Ensure that the optimistic update matches the server's expected response format to avoid UI inconsistencies.
Warning: Rollback logic must be implemented carefully to prevent UI flickering or incorrect state.
Best Practices for Optimistic Updates
- Only apply optimistic updates to actions that are likely to succeed (e.g., local mutations, not network errors)
- Implement rollback logic for failed server requests
- Use unique identifiers to track local vs. server state
- Provide visual feedback (e.g., loading indicators) to users during optimistic updates
8. Creating Offline Fallback UI
Example: Basic Offline UI Component
{'import React from 'react';\nimport { View, Text, StyleSheet } from 'react-native';\n\nconst OfflineFallback = () => {\n return (\n <View style={styles.container}>\n <Text style={styles.title}>Offline Mode</Text>\n <Text style={styles.message}>You are currently offline. Some features may be limited.</Text>\n </View>\n );\n};\n\nconst styles = StyleSheet.create({\n container: {\n flex: 1,\n justifyContent: 'center',\n alignItems: 'center',\n padding: 20,\n },\n title: {\n fontSize: 20,\n fontWeight: 'bold',\n marginBottom: 10,\n },\n message: {\n fontSize: 16,\n textAlign: 'center',\n },\n});\n\nexport default OfflineFallback;'}
| Property | Description | Usage |
|---|---|---|
| container | Root container for the fallback UI | Center content with padding |
| title | Text style for the offline title | Bold and larger font size |
| message | Text style for the offline message | Centered and readable font size |
Note: The fallback UI should be minimal and clearly indicate the offline state to the user.
Warning: Ensure that the fallback UI is accessible and provides clear instructions for re-establishing connectivity.
Key Points
- Display a clear offline indicator to the user.
- Provide actionable feedback or instructions for reconnection.
- Keep the UI simple and unobtrusive to avoid confusion.
9. Managing Data Reconciliation
Example: Basic Offline Data Sync Strategy
import AsyncStorage from '@react-native-async-storage/async-storage';
// Store local changes
const storeLocalChanges = async (changes) => {
const localChanges = await AsyncStorage.getItem('localChanges');
const changesArray = localChanges ? JSON.parse(localChanges) : [];
changesArray.push(changes);
await AsyncStorage.setItem('localChanges', JSON.stringify(changesArray));
};
// Sync with server
const syncWithServer = async () => {
const localChanges = await AsyncStorage.getItem('localChanges');
if (!localChanges) return;
const changesArray = JSON.parse(localChanges);
try {
const response = await fetch('/api/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(changesArray),
});
if (response.ok) {
await AsyncStorage.removeItem('localChanges');
}
} catch (error) {
console.error('Sync failed:', error);
}
};
| Property | Description | Values | Notes |
|---|---|---|---|
| localChanges | Stores pending changes | Array |
Used for offline data reconciliation |
| syncWithServer | Syncs local changes to server | function |
Handles conflict resolution logic |
Note: Conflicts should be resolved by comparing timestamps or version numbers.
Warning: Ensure all local changes are persisted before attempting sync.
Key Points for Data Reconciliation
- Local changes are stored in
AsyncStorage - Server sync is performed when network is available
- Conflict resolution logic must be implemented
- Changes are cleared only after successful sync
10. Testing Offline Scenarios
Example: Mocking Network Requests
import { NetInfo } from 'react-native';
// Mock network status
NetInfo.fetch().then((state) => {
console.log("Network type:", state.type);
console.log("Is connected:", state.isConnected);
});
// Simulate offline
NetInfo.addEventListener("connectionChange", (netInfo) => {
if (!netInfo.isConnected) {
console.log("Offline");
}
});
| Method | Description | Return Value |
|---|---|---|
| NetInfo.fetch() | Fetches current network information | Promise<NetworkInfo> |
| NetInfo.addEventListener() | Subscribes to network changes | unsubscribe function |