PWA Offline Mode Implementation
Offline mode allows the application to partially function without internet: show cached pages, accept orders for later sync, display last data instead of error. Implemented via Service Worker + IndexedDB.
What to cache for offline
App Shell — minimal HTML/CSS/JS for interface to work. Cached on Service Worker installation.
Data — recently loaded pages, user favorites, cart. Cached at runtime.
// sw.js: strategy for different content types
const SHELL_CACHE = 'shell-v1';
const CONTENT_CACHE = 'content-v1';
const IMAGES_CACHE = 'images-v1';
const APP_SHELL = ['/', '/cart', '/wishlist', '/offline.html'];
// Cache all visited HTML pages
self.addEventListener('fetch', event => {
if (event.request.headers.get('Accept')?.includes('text/html')) {
event.respondWith(networkFirstWithOfflineFallback(event.request));
}
});
async function networkFirstWithOfflineFallback(request) {
const cache = await caches.open(CONTENT_CACHE);
try {
const response = await Promise.race([
fetch(request),
new Promise((_, reject) => setTimeout(reject, 3000, new Error('timeout')))
]);
cache.put(request, response.clone());
return response;
} catch {
const cached = await cache.match(request);
if (cached) return cached;
// Return offline page with explanation
return caches.match('/offline.html');
}
}
Network status indicator
// useNetworkStatus.ts
export function useNetworkStatus() {
const [isOnline, setIsOnline] = useState(navigator.onLine);
const [wasOffline, setWasOffline] = useState(false);
useEffect(() => {
const handleOnline = () => {
setIsOnline(true);
if (wasOffline) {
// Sync deferred actions
syncPendingActions();
setWasOffline(false);
}
};
const handleOffline = () => {
setIsOnline(false);
setWasOffline(true);
};
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, [wasOffline]);
return { isOnline, wasOffline };
}
// In component
function OfflineBanner() {
const { isOnline } = useNetworkStatus();
if (isOnline) return null;
return (
<div className="offline-banner">
No connection. Showing saved data.
</div>
);
}
IndexedDB for offline data
// db.ts — Dexie.js (IndexedDB wrapper)
import Dexie, { type Table } from 'dexie';
interface CachedProduct {
id: number;
slug: string;
name: string;
price: number;
image: string;
cachedAt: Date;
}
interface PendingAction {
id?: number;
type: 'add_to_cart' | 'add_to_wishlist' | 'submit_review';
payload: Record<string, unknown>;
createdAt: Date;
}
class AppDatabase extends Dexie {
products!: Table<CachedProduct>;
pendingActions!: Table<PendingAction>;
constructor() {
super('AppDatabase');
this.version(1).stores({
products: 'id, slug, cachedAt',
pendingActions: '++id, type, createdAt',
});
}
}
export const db = new AppDatabase();
Deferred actions (Optimistic UI)
// Add to cart — works offline
async function addToCart(productId: number, quantity: number) {
const { isOnline } = useNetworkStatus();
if (isOnline) {
// Online — normal request
await api.post('/cart/items', { productId, quantity });
} else {
// Offline — save for sync
await db.pendingActions.add({
type: 'add_to_cart',
payload: { productId, quantity },
createdAt: new Date(),
});
// Update local state (optimistic)
updateCartLocally(productId, quantity);
// Notify user
showToast('Item added. Will sync when online');
}
}
// Sync on reconnect
async function syncPendingActions() {
const pending = await db.pendingActions.toArray();
for (const action of pending) {
try {
await processAction(action);
await db.pendingActions.delete(action.id!);
} catch (err) {
console.error('Sync failed for action:', action, err);
}
}
}
Background Sync via Service Worker
// sw.js: auto-sync on reconnect
self.addEventListener('sync', event => {
if (event.tag === 'sync-cart') {
event.waitUntil(syncCartItems());
}
if (event.tag === 'sync-reviews') {
event.waitUntil(syncPendingReviews());
}
});
async function syncCartItems() {
const db = await openDB('AppDatabase', 1);
const pending = await db.getAll('pendingActions');
for (const action of pending.filter(a => a.type === 'add_to_cart')) {
const response = await fetch('/api/cart/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(action.payload),
});
if (response.ok) {
await db.delete('pendingActions', action.id);
}
}
}
// Register sync from page
async function registerBackgroundSync() {
const registration = await navigator.serviceWorker.ready;
if ('sync' in registration) {
await (registration as SyncRegistration).sync.register('sync-cart');
}
}
Offline for catalog with search
// Cache recent search results
const SEARCH_CACHE_SIZE = 20;
async function search(query: string): Promise<Product[]> {
const { isOnline } = getNetworkStatus();
if (isOnline) {
const results = await api.get('/search', { params: { q: query } });
// Cache result
await db.searchCache.put({ query, results, cachedAt: new Date() });
return results;
} else {
// Offline — search IndexedDB
const cached = await db.searchCache.get(query);
if (cached) return cached.results;
// Local search through cached products
return db.products
.filter(p => p.name.toLowerCase().includes(query.toLowerCase()))
.toArray();
}
}
Implementation time: 2–3 days for full offline mode with IndexedDB and Background Sync.







