Skip to main content

Offline Support

Building mobile applications that work seamlessly without internet connectivity.

Overview

Offline support is essential for mobile applications to provide a reliable user experience regardless of network conditions. This guide covers strategies for implementing offline functionality, data synchronization, and graceful degradation when connectivity is lost.

Why Offline Support Matters

  • Reliability: Apps work anywhere, anytime
  • Performance: Faster load times using cached data
  • User Experience: No frustrating "No Internet" screens
  • Data Integrity: Queue operations and sync when online
  • Cost Efficiency: Reduce data usage and server load

Offline Strategies

1. Cache-First Strategy

Load from cache first, then update from network.

const fetchData = async (url) => {
// Try cache first
const cached = await getCachedData(url);
if (cached) {
return cached;
}

// Fetch from network if not cached
try {
const response = await fetch(url);
const data = await response.json();

// Cache the response
await cacheData(url, data);

return data;
} catch (error) {
throw new Error('Unable to fetch data');
}
};

2. Network-First Strategy

Try network first, fall back to cache.

const fetchData = async (url) => {
try {
const response = await fetch(url);
const data = await response.json();

// Update cache
await cacheData(url, data);

return data;
} catch (error) {
// Network failed, try cache
const cached = await getCachedData(url);
if (cached) {
return cached;
}

throw new Error('No data available');
}
};

3. Offline-First Strategy

Always work with local data, sync in background.

const saveData = async (data) => {
// Save locally first
await saveToLocalStorage(data);

// Try to sync with server
if (navigator.onLine) {
try {
await syncWithServer(data);
} catch (error) {
// Queue for later sync
await queueForSync(data);
}
} else {
// Queue for sync when online
await queueForSync(data);
}
};

Detecting Network Status

Using Network API

import { Network } from '@capacitor/network';

const NetworkStatus = () => {
const [isOnline, setIsOnline] = useState(true);
const [connectionType, setConnectionType] = useState('unknown');

useEffect(() => {
// Get initial status
const checkStatus = async () => {
const status = await Network.getStatus();
setIsOnline(status.connected);
setConnectionType(status.connectionType);
};

checkStatus();

// Listen for changes
const listener = Network.addListener('networkStatusChange', (status) => {
setIsOnline(status.connected);
setConnectionType(status.connectionType);

if (status.connected) {
handleBackOnline();
} else {
handleOffline();
}
});

return () => {
listener.remove();
};
}, []);

return (
<div>
<div className={`status-badge ${isOnline ? 'online' : 'offline'}`}>
{isOnline ? '🟢 Online' : '🔴 Offline'}
</div>
{isOnline && <p>Connection: {connectionType}</p>}
</div>
);
};

Using Browser API

const useOnlineStatus = () => {
const [isOnline, setIsOnline] = useState(navigator.onLine);

useEffect(() => {
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);

window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);

return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);

return isOnline;
};

Local Storage Solutions

LocalStorage

Simple key-value storage for small amounts of data.

// Save data
const saveToLocalStorage = (key, data) => {
try {
localStorage.setItem(key, JSON.stringify(data));
} catch (error) {
console.error('Failed to save to localStorage:', error);
}
};

// Load data
const loadFromLocalStorage = (key) => {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : null;
} catch (error) {
console.error('Failed to load from localStorage:', error);
return null;
}
};

// Remove data
const removeFromLocalStorage = (key) => {
localStorage.removeItem(key);
};

// Clear all
const clearLocalStorage = () => {
localStorage.clear();
};

IndexedDB

Structured database for larger datasets.

// Initialize database
const openDatabase = () => {
return new Promise((resolve, reject) => {
const request = indexedDB.open('MyAppDB', 1);

request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);

request.onupgradeneeded = (event) => {
const db = event.target.result;

// Create object stores
if (!db.objectStoreNames.contains('items')) {
const objectStore = db.createObjectStore('items', { keyPath: 'id' });
objectStore.createIndex('timestamp', 'timestamp', { unique: false });
}

if (!db.objectStoreNames.contains('syncQueue')) {
db.createObjectStore('syncQueue', { autoIncrement: true });
}
};
});
};

// Save to IndexedDB
const saveToIndexedDB = async (storeName, data) => {
const db = await openDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction([storeName], 'readwrite');
const store = transaction.objectStore(storeName);
const request = store.put(data);

request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
};

// Load from IndexedDB
const loadFromIndexedDB = async (storeName, key) => {
const db = await openDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction([storeName], 'readonly');
const store = transaction.objectStore(storeName);
const request = store.get(key);

request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
};

// Load all from IndexedDB
const loadAllFromIndexedDB = async (storeName) => {
const db = await openDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction([storeName], 'readonly');
const store = transaction.objectStore(storeName);
const request = store.getAll();

request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
};

// Delete from IndexedDB
const deleteFromIndexedDB = async (storeName, key) => {
const db = await openDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction([storeName], 'readwrite');
const store = transaction.objectStore(storeName);
const request = store.delete(key);

request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
};

Capacitor Storage

Cross-platform key-value storage.

import { Storage } from '@capacitor/storage';

// Save data
const saveToStorage = async (key, value) => {
await Storage.set({
key: key,
value: JSON.stringify(value),
});
};

// Load data
const loadFromStorage = async (key) => {
const { value } = await Storage.get({ key: key });
return value ? JSON.parse(value) : null;
};

// Remove data
const removeFromStorage = async (key) => {
await Storage.remove({ key: key });
};

// Clear all
const clearStorage = async () => {
await Storage.clear();
};

// Get all keys
const getAllKeys = async () => {
const { keys } = await Storage.keys();
return keys;
};

Data Synchronization

Sync Queue Implementation

class SyncQueue {
constructor() {
this.queueKey = 'syncQueue';
}

async add(operation) {
const queue = await this.getQueue();
queue.push({
...operation,
id: Date.now(),
timestamp: new Date().toISOString(),
retries: 0,
});
await this.saveQueue(queue);
}

async getQueue() {
const queue = await loadFromStorage(this.queueKey);
return queue || [];
}

async saveQueue(queue) {
await saveToStorage(this.queueKey, queue);
}

async process() {
if (!navigator.onLine) {
console.log('Offline, skipping sync');
return;
}

const queue = await this.getQueue();
if (queue.length === 0) return;

console.log(`Processing ${queue.length} queued operations`);

const remaining = [];

for (const operation of queue) {
try {
await this.executeOperation(operation);
console.log('Operation synced:', operation.id);
} catch (error) {
console.error('Sync failed:', error);

// Retry logic
operation.retries++;
if (operation.retries < 3) {
remaining.push(operation);
} else {
console.error('Max retries reached, dropping operation:', operation);
}
}
}

await this.saveQueue(remaining);
}

async executeOperation(operation) {
switch (operation.type) {
case 'CREATE':
return fetch(operation.url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(operation.data),
});

case 'UPDATE':
return fetch(operation.url, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(operation.data),
});

case 'DELETE':
return fetch(operation.url, {
method: 'DELETE',
});

default:
throw new Error(`Unknown operation type: ${operation.type}`);
}
}

async clear() {
await this.saveQueue([]);
}
}

// Usage
const syncQueue = new SyncQueue();

// Add operation to queue
await syncQueue.add({
type: 'CREATE',
url: '/api/items',
data: { name: 'New Item', description: 'Description' },
});

// Process queue when online
window.addEventListener('online', () => {
syncQueue.process();
});

Background Sync

Using Service Worker Background Sync API.

// Register background sync
const registerBackgroundSync = async () => {
if ('serviceWorker' in navigator && 'sync' in navigator.serviceWorker) {
const registration = await navigator.serviceWorker.ready;
await registration.sync.register('syncData');
}
};

// Service Worker (service-worker.js)
self.addEventListener('sync', (event) => {
if (event.tag === 'syncData') {
event.waitUntil(syncData());
}
});

const syncData = async () => {
// Get queued operations
const queue = await getQueuedOperations();

for (const operation of queue) {
try {
await fetch(operation.url, {
method: operation.method,
headers: operation.headers,
body: operation.body,
});

// Remove from queue after successful sync
await removeFromQueue(operation.id);
} catch (error) {
console.error('Sync failed:', error);
}
}
};

Offline Data Management

Caching Strategy with React Hook

const useOfflineData = (url, fetchFunction) => {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const isOnline = useOnlineStatus();

useEffect(() => {
const loadData = async () => {
setLoading(true);

// Try to load cached data first
const cached = await loadFromStorage(url);
if (cached) {
setData(cached);
setLoading(false);
}

// If online, fetch fresh data
if (isOnline) {
try {
const fresh = await fetchFunction();
setData(fresh);
await saveToStorage(url, fresh);
setError(null);
} catch (err) {
setError(err);
// If fetch fails but we have cache, use it
if (!cached) {
setLoading(false);
}
}
}

setLoading(false);
};

loadData();
}, [url, isOnline]);

return { data, loading, error, isOnline };
};

// Usage
const ProductList = () => {
const { data: products, loading, error, isOnline } = useOfflineData(
'products',
() => fetch('/api/products').then(r => r.json())
);

return (
<div>
{!isOnline && (
<div className="offline-banner">
You're offline. Showing cached data.
</div>
)}

{loading && <div>Loading...</div>}
{error && <div>Error: {error.message}</div>}

<div className="product-grid">
{products?.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
</div>
);
};

Optimistic Updates

Update UI immediately, sync later.

const useOptimisticUpdate = () => {
const [items, setItems] = useState([]);
const syncQueue = new SyncQueue();
const isOnline = useOnlineStatus();

const addItem = async (item) => {
// Optimistic update
const newItem = { ...item, id: `temp-${Date.now()}`, pending: true };
setItems([...items, newItem]);

// Queue for sync
await syncQueue.add({
type: 'CREATE',
url: '/api/items',
data: item,
localId: newItem.id,
});

// Process immediately if online
if (isOnline) {
try {
const response = await fetch('/api/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item),
});

const serverItem = await response.json();

// Replace temp item with server response
setItems(items.map(i =>
i.id === newItem.id ? { ...serverItem, pending: false } : i
));
} catch (error) {
// Mark as failed but keep in list
setItems(items.map(i =>
i.id === newItem.id ? { ...i, pending: false, error: true } : i
));
}
}
};

const deleteItem = async (id) => {
// Optimistic delete
setItems(items.filter(i => i.id !== id));

// Queue for sync
await syncQueue.add({
type: 'DELETE',
url: `/api/items/${id}`,
});

// Sync if online
if (isOnline) {
try {
await fetch(`/api/items/${id}`, { method: 'DELETE' });
} catch (error) {
// Restore item on error
const item = items.find(i => i.id === id);
if (item) {
setItems([...items, item]);
}
}
}
};

return { items, addItem, deleteItem };
};

Service Workers

Basic Service Worker Setup

// Register service worker
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/service-worker.js')
.then(registration => {
console.log('Service Worker registered:', registration);
})
.catch(error => {
console.error('Service Worker registration failed:', error);
});
}

// service-worker.js
const CACHE_NAME = 'my-app-v1';
const urlsToCache = [
'/',
'/index.html',
'/styles.css',
'/app.js',
'/offline.html',
];

// Install event - cache assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(urlsToCache))
);
});

// Fetch event - serve from cache
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then(response => {
// Cache hit - return response
if (response) {
return response;
}

// Clone the request
const fetchRequest = event.request.clone();

return fetch(fetchRequest).then(response => {
// Check if valid response
if (!response || response.status !== 200 || response.type !== 'basic') {
return response;
}

// Clone the response
const responseToCache = response.clone();

caches.open(CACHE_NAME)
.then(cache => {
cache.put(event.request, responseToCache);
});

return response;
}).catch(() => {
// Return offline page for navigation requests
if (event.request.mode === 'navigate') {
return caches.match('/offline.html');
}
});
})
);
});

// Activate event - clean old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cacheName => {
if (cacheName !== CACHE_NAME) {
return caches.delete(cacheName);
}
})
);
})
);
});

Offline UI Patterns

Offline Banner

const OfflineBanner = () => {
const isOnline = useOnlineStatus();

if (isOnline) return null;

return (
<div className="offline-banner">
<div className="offline-content">
<span className="offline-icon">📡</span>
<span>You're offline. Some features may be limited.</span>
</div>
</div>
);
};

Sync Status Indicator

const SyncStatus = () => {
const [syncQueue, setSyncQueue] = useState([]);
const [syncing, setSyncing] = useState(false);

const checkQueue = async () => {
const queue = await new SyncQueue().getQueue();
setSyncQueue(queue);
};

useEffect(() => {
checkQueue();
const interval = setInterval(checkQueue, 5000);
return () => clearInterval(interval);
}, []);

if (syncQueue.length === 0) return null;

return (
<div className="sync-status">
{syncing ? (
<span>🔄 Syncing...</span>
) : (
<span>📤 {syncQueue.length} pending changes</span>
)}
</div>
);
};

Testing Offline Functionality

// Simulate offline mode in tests
describe('Offline functionality', () => {
beforeEach(() => {
// Mock navigator.onLine
Object.defineProperty(navigator, 'onLine', {
writable: true,
value: false,
});
});

test('shows offline banner when offline', () => {
const { getByText } = render(<App />);
expect(getByText(/you're offline/i)).toBeInTheDocument();
});

test('queues operations when offline', async () => {
const syncQueue = new SyncQueue();
await syncQueue.add({ type: 'CREATE', url: '/api/items', data: {} });

const queue = await syncQueue.getQueue();
expect(queue.length).toBe(1);
});
});

Best Practices

1. Cache Intelligently

Cache essential assets and API responses, but not everything.

2. Provide Clear Feedback

Show users when they're offline and what functionality is limited.

3. Queue Operations

Don't lose user data - queue operations for later sync.

4. Handle Conflicts

Implement conflict resolution for simultaneous edits.

5. Expire Old Cache

Remove outdated cached data to prevent stale information.

const isCacheExpired = (timestamp, maxAge = 3600000) => {
return Date.now() - timestamp > maxAge;
};