This content originally appeared on DEV Community and was authored by Oghenetega Adiri
Introduction
In today's mobile-first world, users expect apps to work seamlessly regardless of network connectivity. Whether you're building a visitor management system, a field service app, or any data-driven application, implementing robust offline functionality is crucial for delivering a great user experience.
In this comprehensive guide, I'll walk you through building a production-ready offline system in React Native, covering everything from data synchronization to conflict resolution. We'll explore real-world implementations from a visitor management system that handles offline code verification, queue management, and intelligent sync strategies.
Table of Contents
- Architecture Overview
- Storage Strategy
- Network State Management
- Offline Queue Implementation
- Data Synchronization
- Context-Based State Management
- Handling Edge Cases
- Best Practices
Architecture Overview
Our offline system follows a three-layer architecture:
- Storage Layer: Handles persistent data storage using AsyncStorage and SecureStore
- Service Layer: Manages business logic, API calls, and offline fallbacks
- Context Layer: Provides global state management and sync coordination
┌─────────────────────────────────────┐
│ UI Components (Screens) │
└─────────────────┬───────────────────┘
│
┌─────────────────▼───────────────────┐
│ Context Providers │
│ (AppContext, CodeContext, etc.) │
└─────────────────┬───────────────────┘
│
┌─────────────────▼───────────────────┐
│ Service Layer │
│ (CodeService, NetworkService) │
└─────────────────┬───────────────────┘
│
┌─────────────────▼───────────────────┐
│ Storage Layer │
│ (AsyncStorage, SecureStore) │
└─────────────────────────────────────┘
Storage Strategy
Handling Storage Size Limits
React Native's SecureStore has a 2KB limit, which can be problematic for larger data structures. Here's how to handle it elegantly:
import * as SecureStore from 'expo-secure-store';
import AsyncStorage from '@react-native-async-storage/async-storage';
const SECURE_STORE_SIZE_LIMIT = 2000;
class EnhancedSecureStorage {
static async setItem(key, value, options = {}) {
try {
const stringValue = typeof value === 'string' ? value : JSON.stringify(value);
const byteSize = new Blob([stringValue]).size;
// If value exceeds limit, use AsyncStorage with overflow handling
if (byteSize > SECURE_STORE_SIZE_LIMIT) {
console.warn(`${key} is ${byteSize} bytes, using AsyncStorage`);
await AsyncStorage.setItem(`secure_overflow_${key}`, stringValue);
await SecureStore.setItemAsync(
key,
`__OVERFLOW__:secure_overflow_${key}`,
options
);
return;
}
await SecureStore.setItemAsync(key, stringValue, options);
} catch (error) {
// Fallback logic
if (error.message?.includes('too large')) {
const stringValue = typeof value === 'string' ? value : JSON.stringify(value);
await AsyncStorage.setItem(`secure_overflow_${key}`, stringValue);
await SecureStore.setItemAsync(
key,
`__OVERFLOW__:secure_overflow_${key}`,
options
);
} else {
throw error;
}
}
}
static async getItem(key) {
const value = await SecureStore.getItemAsync(key);
if (!value) return null;
// Handle overflow references
if (value.startsWith('__OVERFLOW__:')) {
const asyncStorageKey = value.replace('__OVERFLOW__:', '');
return await AsyncStorage.getItem(asyncStorageKey);
}
return value;
}
}
Structured Storage with Metadata
Always store data with metadata for better cache management:
export const storeOfflineCodes = async (codes, syncTime = null) => {
try {
const data = {
codes: codes || [],
lastSync: syncTime || new Date().toISOString(),
version: '1.0'
};
await AsyncStorage.setItem(
STORAGE_KEYS.OFFLINE_CODES,
JSON.stringify(data)
);
await AsyncStorage.setItem(STORAGE_KEYS.LAST_SYNC, data.lastSync);
return { success: true };
} catch (error) {
console.error('Error storing offline codes:', error);
return { success: false, error: error.message };
}
};
export const getOfflineCodes = async () => {
try {
const data = await AsyncStorage.getItem(STORAGE_KEYS.OFFLINE_CODES);
if (data) {
const parsedData = JSON.parse(data);
// Handle legacy format
if (Array.isArray(parsedData)) {
return { codes: parsedData, lastSync: null, success: true };
}
// Handle new format with metadata
return {
codes: parsedData.codes || [],
lastSync: parsedData.lastSync,
version: parsedData.version,
success: true
};
}
return { codes: [], lastSync: null, success: true };
} catch (error) {
console.error('Error getting offline codes:', error);
return { codes: [], lastSync: null, success: false };
}
};
Network State Management
Robust network detection is crucial. Use @react-native-community/netinfo for reliable network state monitoring:
import NetInfo from '@react-native-community/netinfo';
// In your AppContext or service
useEffect(() => {
const unsubscribe = NetInfo.addEventListener(networkState => {
const isOnline = networkState.isConnected;
const wasOffline = !previousOnlineStatus.current;
dispatch({
type: 'SET_ONLINE_STATUS',
payload: isOnline
});
// Trigger auto-sync when coming back online
if (isOnline && wasOffline && settings.autoSync) {
console.log('Network restored, triggering auto-sync...');
// Delay to ensure stable connection
autoSyncTimeout.current = setTimeout(() => {
if (isMounted.current) {
handleAutoSync();
}
}, 2000);
}
previousOnlineStatus.current = isOnline;
});
return () => unsubscribe();
}, []);
Offline Queue Implementation
Queue Structure
Design your offline queue to handle retry logic and failure tracking:
export const storeOfflineQueue = async (verifications) => {
try {
const data = {
verifications: verifications || [],
lastUpdated: new Date().toISOString(),
version: '1.0'
};
await AsyncStorage.setItem(
STORAGE_KEYS.OFFLINE_QUEUE,
JSON.stringify(data)
);
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
};
Adding Items to Queue
When operations fail offline, queue them for later sync:
static async addToOfflineQueue(verificationData) {
try {
const currentQueue = await getOfflineQueue();
const updatedQueue = [
...currentQueue,
{
...verificationData,
retryCount: 0,
firstAttemptAt: new Date().toISOString(),
lastAttemptAt: new Date().toISOString(),
},
];
await storeOfflineQueue(updatedQueue);
return { success: true };
} catch (error) {
console.error('Error adding to offline queue:', error);
return { success: false };
}
}
Intelligent Retry Logic
Implement exponential backoff and failure handling:
static async syncOfflineVerifications() {
const offlineQueue = await getOfflineQueue();
if (offlineQueue.length === 0) {
return { success: true, syncedCount: 0 };
}
const syncResults = [];
const failedSyncs = [];
const permanentFailures = [];
const MAX_RETRY_ATTEMPTS = 5;
const MAX_AGE_HOURS = 72; // 3 days
for (const verification of offlineQueue) {
const now = new Date();
const firstAttempt = new Date(
verification.firstAttemptAt || verification.timestamp
);
const ageInHours = (now - firstAttempt) / (1000 * 60 * 60);
const retryCount = verification.retryCount || 0;
// Check if verification is too old
if (ageInHours > MAX_AGE_HOURS) {
permanentFailures.push({
...verification,
failureReason: 'STALE_VERIFICATION',
failedAt: now.toISOString(),
});
continue;
}
// Check retry limit
if (retryCount >= MAX_RETRY_ATTEMPTS) {
permanentFailures.push({
...verification,
failureReason: 'MAX_RETRIES_EXCEEDED',
failedAt: now.toISOString(),
});
continue;
}
try {
const response = await api.post('/codes/sync-offline-verification', {
verificationId: verification.id,
code: verification.code,
offlineTimestamp: verification.timestamp,
retryCount: retryCount,
});
if (response.data.success) {
syncResults.push(verification.id);
} else {
// Check for permanent failures from backend
if (response.data.permanentFailure) {
permanentFailures.push({
...verification,
failureReason: response.data.errorCode,
serverMessage: response.data.message,
});
} else {
// Retryable failure
failedSyncs.push({
...verification,
retryCount: retryCount + 1,
lastAttemptAt: now.toISOString(),
lastError: response.data.message,
});
}
}
} catch (error) {
// Network errors are retryable, HTTP errors might be permanent
if (error.response?.status === 404 || error.response?.status === 409) {
permanentFailures.push({
...verification,
failureReason: 'PERMANENT_ERROR',
serverMessage: error.response?.data?.message,
});
} else {
failedSyncs.push({
...verification,
retryCount: retryCount + 1,
lastAttemptAt: now.toISOString(),
lastError: error.message,
});
}
}
}
// Update queue with only retryable failures
await storeOfflineQueue(failedSyncs);
if (permanentFailures.length > 0) {
await this.storePermanentFailures(permanentFailures);
}
return {
success: true,
syncedCount: syncResults.length,
failedCount: failedSyncs.length,
permanentFailureCount: permanentFailures.length,
};
}
Data Synchronization
Unified Verification Approach
Support both online and offline operations seamlessly:
static async verifyCode(code, notes = "", isExit = false) {
try {
const isConnected = await this.isOnline();
if (isConnected) {
try {
const endpoint = isExit ? "/codes/verify-exit" : "/codes/verify";
const response = await api.post(endpoint, {
code,
notes,
isExit,
});
// Background sync offline queue on successful online operation
this.syncOfflineVerifications().catch((error) => {
console.log("Background sync failed:", error);
});
return {
success: true,
data: response.data.data,
verifiedOnline: true,
};
} catch (error) {
// Fallback to offline only on network errors
if (
error.code === "NETWORK_ERROR" ||
!error.response ||
error.response.status >= 500
) {
console.log("Falling back to offline verification");
return await this.verifyCodeOffline(code, notes, isExit);
}
return {
success: false,
message: getErrorMessage(error),
};
}
} else {
return await this.verifyCodeOffline(code, notes, isExit);
}
} catch (error) {
console.error("Verify code error:", error);
return {
success: false,
message: "Failed to verify code",
};
}
}
Offline Data Verification
Implement local verification with state tracking:
static async verifyCodeOffline(inputCode, notes = "", isExit = false) {
try {
const { codes } = await getOfflineCodes();
if (!codes || codes.length === 0) {
return {
success: false,
message: "No offline codes available. Please sync when online.",
needsSync: true,
};
}
const cleanCode = inputCode.replace(/\s/g, "");
const now = new Date();
// Find matching code with proper state validation
let matchingCode = codes.find((codeData) => {
if (codeData.code !== cleanCode) return false;
const isExpired = new Date(codeData.expiresAt) <= now;
if (isExpired || codeData.isRevoked) return false;
// Handle different code types and verification modes
if (codeData.type === "regular") {
if (isExit) {
return (
codeData.entryStatus === "entered" &&
!codeData.exitVerified &&
codeData.exitRequired
);
} else {
return codeData.entryStatus === "pending" && !codeData.isUsed;
}
}
// Additional type-specific logic...
return false;
});
if (matchingCode) {
const verificationId = `offline_${isExit ? "exit" : "entry"}_${Date.now()}`;
// Update local state
const updatedCodes = codes.map((code) =>
code.code === cleanCode
? {
...code,
isUsed: !isExit ? true : code.isUsed,
usedAt: !isExit ? now.toISOString() : code.usedAt,
exitVerified: isExit ? true : code.exitVerified,
exitVerifiedAt: isExit ? now.toISOString() : code.exitVerifiedAt,
entryStatus: isExit ? "exited" : "entered",
}
: code
);
await storeOfflineCodes(updatedCodes);
// Add to sync queue
await this.addToOfflineQueue({
id: verificationId,
code: cleanCode,
codeId: matchingCode._id,
notes,
timestamp: now.toISOString(),
verifiedOffline: true,
synced: false,
verificationType: isExit ? "exit" : "entry",
});
return {
success: true,
data: {
...matchingCode,
verifiedOffline: true,
verificationId,
},
};
}
return {
success: false,
message: "Invalid code or code not found.",
};
} catch (error) {
console.error("Offline verify error:", error);
return {
success: false,
message: "Failed to verify code offline",
};
}
}
Bidirectional Sync
Implement syncing in both directions - downloading fresh data and uploading queued operations:
static async syncCodesForOffline() {
const isConnected = await this.isOnline();
if (!isConnected) {
return { success: false, message: "No internet connection" };
}
// First, sync offline verifications back to server
const offlineSync = await this.syncOfflineVerifications();
// Then download fresh codes
const response = await api.get("/codes/sync");
const codes = response.data.data.codes || [];
// Preserve server state exactly
const processedCodes = codes.map((code) => ({
...code,
effectiveExpiresAt: code.effectiveExpiresAt || code.expiresAt,
exitRequired: code.exitRequired !== undefined ? code.exitRequired : false,
verifiedOffline: false,
// Preserve critical state fields from server
entryStatus: code.entryStatus,
exitVerified: code.exitVerified,
isUsed: code.isUsed,
usedAt: code.usedAt,
}));
const syncTime = response.data.syncTime || new Date().toISOString();
await storeOfflineCodes(processedCodes, syncTime);
return {
success: true,
codesCount: processedCodes.length,
syncTime,
offlineVerificationsSynced: offlineSync.syncedCount || 0,
};
}
Context-Based State Management
App-Wide Context
Create a central context for managing offline state:
export const AppProvider = ({ children }) => {
const [state, dispatch] = useReducer(appReducer, initialState);
const previousOnlineStatus = useRef(state.isOnline);
const autoSyncTimeout = useRef(null);
const isMounted = useRef(true);
useEffect(() => {
isMounted.current = true;
const unsubscribe = NetInfo.addEventListener(networkState => {
const isOnline = networkState.isConnected;
const wasOffline = !previousOnlineStatus.current;
dispatch({
type: 'SET_ONLINE_STATUS',
payload: isOnline
});
if (isOnline && wasOffline && state.settings.autoSync) {
if (autoSyncTimeout.current) {
clearTimeout(autoSyncTimeout.current);
}
autoSyncTimeout.current = setTimeout(() => {
if (isMounted.current) {
handleAutoSync();
}
}, 2000);
}
previousOnlineStatus.current = isOnline;
});
loadInitialData();
return () => {
isMounted.current = false;
unsubscribe();
if (autoSyncTimeout.current) {
clearTimeout(autoSyncTimeout.current);
}
};
}, []);
const handleAutoSync = async () => {
try {
const result = await syncData();
if (result.success) {
dispatch({
type: 'SET_LAST_AUTO_SYNC',
payload: new Date().toISOString()
});
if (result.offlineVerificationsSynced > 0) {
addNotification({
type: 'success',
title: 'Auto-Sync Complete',
message: `${result.codesCount} codes synced, ${result.offlineVerificationsSynced} offline verifications uploaded`,
});
}
}
} catch (error) {
console.error('Auto-sync error:', error);
}
};
const syncData = async () => {
if (!state.isOnline) {
return { success: false, message: 'No internet connection' };
}
const result = await CodeService.syncCodesForOffline();
if (result.success) {
dispatch({
type: 'SET_LAST_SYNC_TIME',
payload: result.syncTime
});
}
return result;
};
return (
<AppContext.Provider value={{ ...state, syncData }}>
{children}
</AppContext.Provider>
);
};
Feature-Specific Context
Create specialized contexts for different features:
export const CodeProvider = ({ children }) => {
const { user } = useAuth();
const [state, dispatch] = useReducer(codeReducer, initialState);
useEffect(() => {
if (user) {
loadInitialData();
}
}, [user]);
const syncCodes = useCallback(async () => {
if (state.isSyncing) {
return { success: false, message: "Sync already in progress" };
}
try {
dispatch({ type: "SET_SYNCING", payload: true });
const result = await CodeService.syncCodesForOffline();
if (result.success) {
dispatch({
type: "SYNC_SUCCESS",
payload: {
codesCount: result.codesCount,
syncTime: result.syncTime,
},
});
}
return result;
} catch (error) {
dispatch({
type: "SET_SYNC_ERROR",
payload: "Failed to sync codes"
});
return { success: false, message: error.message };
}
}, [state.isSyncing]);
return (
<CodeContext.Provider value={{ ...state, syncCodes }}>
{children}
</CodeContext.Provider>
);
};
Handling Edge Cases
Stale Data Detection
Implement mechanisms to detect and handle stale offline data:
const MAX_AGE_HOURS = 72;
const isDataStale = (lastSyncTime) => {
if (!lastSyncTime) return true;
const now = new Date();
const syncDate = new Date(lastSyncTime);
const ageInHours = (now - syncDate) / (1000 * 60 * 60);
return ageInHours > MAX_AGE_HOURS;
};
// In your UI
if (isDataStale(lastSyncTime)) {
return (
<View style={styles.staleDataWarning}>
<Text>Your offline data is outdated. Please sync when online.</Text>
<Button onPress={syncCodes} title="Sync Now" />
</View>
);
}
Conflict Resolution
Handle conflicts when offline changes conflict with server state:
static async resolveConflict(localData, serverData) {
// Server-wins strategy for most cases
if (serverData.updatedAt > localData.updatedAt) {
return { resolved: serverData, strategy: 'server-wins' };
}
// Client-wins for specific fields
const merged = {
...serverData,
// Preserve local offline operations
offlineOperations: localData.offlineOperations,
};
return { resolved: merged, strategy: 'merged' };
}
Cache Invalidation
Implement proper cache clearing:
const clearCacheAndRefresh = useCallback(async () => {
try {
// Clear all code-related state
dispatch({ type: 'SET_ACTIVE_CODES', payload: { codes: [], count: 0 } });
dispatch({ type: 'SET_LAST_SYNC_TIME', payload: null });
dispatch({ type: 'CLEAR_SYNC_ERROR' });
// Clear storage
await clearOfflineCodes();
await clearOfflineQueue();
// Force reload
setTimeout(() => {
loadActiveCodes();
}, 100);
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
}, [loadActiveCodes]);
Best Practices
1. Always Use Refs for Cleanup
Prevent memory leaks and race conditions:
const isMountedRef = useRef(true);
const timeoutRef = useRef(null);
useEffect(() => {
return () => {
isMountedRef.current = false;
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
// Use in async operations
const fetchData = async () => {
const data = await api.get('/data');
if (isMountedRef.current) {
setState(data);
}
};
2. Implement Progressive Loading
Load data incrementally to improve perceived performance:
const loadInitialData = async () => {
// Load critical data first
const essentialData = await getEssentialData();
setEssentialData(essentialData);
// Load secondary data
setTimeout(async () => {
const secondaryData = await getSecondaryData();
setSecondaryData(secondaryData);
}, 100);
};
3. Provide Clear User Feedback
Always inform users about offline state and sync status:
const OfflineSyncIndicator = () => {
const { isOnline, isSyncing, lastSyncTime, pendingVerifications } = useApp();
return (
<TouchableOpacity onPress={handleManualSync}>
<View style={styles.indicator}>
<Icon name={getStatusIcon()} color={getStatusColor()} />
<Text>{getStatusText()}</Text>
{pendingVerifications > 0 && (
<Badge count={pendingVerifications} />
)}
</View>
</TouchableOpacity>
);
};
4. Implement Exponential Backoff
Don't hammer the server with retry attempts:
const retryWithBackoff = async (fn, maxRetries = 3) => {
for (let i = 0; i < maxRetries; i++) {
try {
return await fn();
} catch (error) {
if (i === maxRetries - 1) throw error;
const delay = Math.min(1000 * Math.pow(2, i), 10000);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
};
5. Log Everything
Comprehensive logging helps debug offline issues:
const logOfflineOperation = (operation, data) => {
console.log(`[OFFLINE] ${operation}:`, {
timestamp: new Date().toISOString(),
operation,
data,
queueLength: offlineQueue.length,
networkStatus: isOnline ? 'online' : 'offline',
});
};
6. Test Offline Scenarios
Always test these scenarios:
- App starts offline
- Connection lost during operation
- Connection restored (immediate and delayed sync)
- Multiple offline operations queued
- Sync failures and retries
- Data conflicts
- Storage limits reached
7. Monitor Performance
Track storage usage and sync performance:
const monitorStorageUsage = async () => {
const offlineCodes = await getOfflineCodes();
const offlineQueue = await getOfflineQueue();
const usage = {
codesCount: offlineCodes.codes?.length || 0,
queueLength: offlineQueue.length || 0,
estimatedSize: estimateSize(offlineCodes) + estimateSize(offlineQueue),
};
console.log('Storage usage:', usage);
if (usage.estimatedSize > 5 * 1024 * 1024) { // 5MB
console.warn('Storage usage high, consider cleanup');
}
};
Conclusion
Building robust offline functionality requires careful planning and implementation across multiple layers of your application. Key takeaways:
- Design for offline-first: Assume users will be offline and make that the default experience
- Implement intelligent syncing: Don't just dump data - handle conflicts, retries, and failures gracefully
- Provide clear feedback: Users need to know what's happening with their data
- Test thoroughly: Offline scenarios are complex and require extensive testing
- Monitor and optimize: Track storage usage, sync performance, and user behavior
The patterns shown here have been battle-tested in a production visitor management system handling thousands of offline verifications. They can be adapted to any React Native application that needs to work offline.
Remember: great offline functionality is invisible to users - they just expect things to work, whether online or offline. Your job is to make that happen seamlessly.
Resources
Have questions or suggestions? Let me know in the comments below!
This content originally appeared on DEV Community and was authored by Oghenetega Adiri
Oghenetega Adiri | Sciencx (2025-10-22T05:26:42+00:00) Building Robust Offline Functionality in React Native: A Complete Guide. Retrieved from https://www.scien.cx/2025/10/22/building-robust-offline-functionality-in-react-native-a-complete-guide/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.