# Chapter 11: Offline & Progressive Enhancement *"The internet is optional. Your app shouldn't be."* --- ## The Connectivity Crisis Sarah was presenting Weather Buddy to investors in a sleek downtown office when disaster struck. "Let me show you our real-time features," she said, clicking to load Miami weather. The loading spinner appeared. And spun. And spun. "Is your WiFi down?" an investor asked. The IT manager checked. "Fiber cut. Whole building's offline." Sarah's app was dead in the water. No cached data. No offline support. Just an endless spinner. "This is why we need offline support," Marcus whispered. "Let me show you how to make Weather Buddy work anywhere - subway tunnels, airplane mode, or fiber cuts." ## Service Workers: Your Offline Guardian Service Workers are like having a smart proxy server in the browser: ```javascript // sw.js - Your service worker self.addEventListener('install', (event) => { console.log('Service Worker installing...') event.waitUntil( caches.open('weather-buddy-v1').then(cache => { // Pre-cache critical resources return cache.addAll([ '/', '/index.html', '/app.js', '/styles.css', '/offline.html' ]) }) ) }) self.addEventListener('activate', (event) => { console.log('Service Worker activating...') // Clean up old caches event.waitUntil( caches.keys().then(cacheNames => { return Promise.all( cacheNames .filter(name => name.startsWith('weather-buddy-') && name !== 'weather-buddy-v1') .map(name => caches.delete(name)) ) }) ) }) self.addEventListener('fetch', (event) => { event.respondWith( caches.match(event.request).then(response => { // Cache hit - return response if (response) { return response } // Not in cache - fetch from network return fetch(event.request) }).catch(() => { // Offline - return offline page return caches.match('/offline.html') }) ) }) ``` ## TypedFetch Offline Integration TypedFetch seamlessly integrates with Service Workers: ```typescript // Configure offline support tf.configure({ offline: { enabled: true, // What to cache cacheStrategy: { '/api/weather/*': { strategy: 'NetworkFirst', // Try network, fallback to cache maxAge: 3600000, // 1 hour broadcastUpdate: true // Notify when cache updates }, '/api/cities/*': { strategy: 'CacheFirst', // Use cache, update in background maxAge: 604800000 // 1 week }, '/api/user/*': { strategy: 'NetworkOnly', // Always require network fallback: '/api/user/offline' // Offline response } }, // Queue failed mutations backgroundSync: { enabled: true, queueName: 'weather-buddy-sync', maxRetentionTime: 24 * 60 * 60 * 1000 // 24 hours }, // IndexedDB for large data indexedDB: { name: 'weather-buddy-offline', version: 1, stores: { weather: { keyPath: 'city' }, forecasts: { keyPath: 'id' }, userPrefs: { keyPath: 'userId' } } } } }) // Check online status const isOnline = tf.isOnline() const status = tf.getOfflineStatus() console.log(status) // { // online: false, // queuedRequests: 5, // cachedEndpoints: 47, // lastSync: '2024-01-20T10:30:00Z' // } ``` ## Offline Request Queuing Never lose user data - queue it for later: ```typescript // Automatic queuing for mutations const { data, queued } = await tf.post('/api/cities/favorites', { data: { city: 'Paris' } }) if (queued) { console.log('Saved offline - will sync when online') showNotification('Saved! Will sync when connected.') } // Manual queue management class OfflineQueue { private db: IDBDatabase async add(request: QueuedRequest) { const tx = this.db.transaction('queue', 'readwrite') const store = tx.objectStore('queue') await store.add({ id: crypto.randomUUID(), timestamp: Date.now(), request: { url: request.url, method: request.method, headers: request.headers, body: request.body }, retries: 0 }) } async process() { if (!navigator.onLine) return const tx = this.db.transaction('queue', 'readonly') const store = tx.objectStore('queue') const requests = await store.getAll() for (const queued of requests) { try { // Replay request const response = await fetch(queued.request.url, { method: queued.request.method, headers: queued.request.headers, body: queued.request.body }) if (response.ok) { // Success - remove from queue await this.remove(queued.id) // Notify app self.postMessage({ type: 'sync-success', request: queued.request, response: await response.json() }) } else { // Failed - retry later await this.updateRetries(queued.id, queued.retries + 1) } } catch (error) { // Network error - keep in queue console.error('Sync failed:', error) } } } async remove(id: string) { const tx = this.db.transaction('queue', 'readwrite') await tx.objectStore('queue').delete(id) } async updateRetries(id: string, retries: number) { const tx = this.db.transaction('queue', 'readwrite') const store = tx.objectStore('queue') const request = await store.get(id) if (request) { request.retries = retries await store.put(request) } } } ``` ## Weather Buddy 11.0: Works Everywhere Let's make Weather Buddy truly offline-first: ```typescript // weather-buddy-11.ts import { tf } from 'typedfetch' // Register service worker async function registerServiceWorker() { if ('serviceWorker' in navigator) { try { const registration = await navigator.serviceWorker.register('/sw.js') console.log('Service Worker registered:', registration.scope) // Handle updates registration.addEventListener('updatefound', () => { const newWorker = registration.installing newWorker?.addEventListener('statechange', () => { if (newWorker.state === 'installed' && navigator.serviceWorker.controller) { // New service worker available showUpdateBanner() } }) }) } catch (error) { console.error('Service Worker registration failed:', error) } } } // Enhanced offline-aware weather service class OfflineWeatherService { private db: IDBDatabase private syncInProgress = false async init() { // Open IndexedDB this.db = await this.openDB() // Setup offline detection this.setupOfflineDetection() // Register background sync if ('sync' in self.registration) { await self.registration.sync.register('weather-sync') } // Listen for sync events self.addEventListener('sync', event => { if (event.tag === 'weather-sync') { event.waitUntil(this.backgroundSync()) } }) } private async openDB(): Promise { return new Promise((resolve, reject) => { const request = indexedDB.open('weather-buddy', 2) request.onerror = () => reject(request.error) request.onsuccess = () => resolve(request.result) request.onupgradeneeded = (event) => { const db = event.target.result // Weather cache if (!db.objectStoreNames.contains('weather')) { const weatherStore = db.createObjectStore('weather', { keyPath: 'city' }) weatherStore.createIndex('timestamp', 'timestamp') } // User actions queue if (!db.objectStoreNames.contains('actions')) { const actionsStore = db.createObjectStore('actions', { keyPath: 'id', autoIncrement: true }) actionsStore.createIndex('timestamp', 'timestamp') } // Sync metadata if (!db.objectStoreNames.contains('sync')) { db.createObjectStore('sync', { keyPath: 'key' }) } } }) } private setupOfflineDetection() { // Network status window.addEventListener('online', () => { console.log('Back online!') this.onOnline() }) window.addEventListener('offline', () => { console.log('Gone offline') this.onOffline() }) // Connection quality if ('connection' in navigator) { const connection = navigator.connection connection.addEventListener('change', () => { console.log('Connection changed:', { effectiveType: connection.effectiveType, downlink: connection.downlink, rtt: connection.rtt, saveData: connection.saveData }) this.adaptToConnection() }) } } private async onOnline() { // Show notification this.showStatus('Back online! Syncing...', 'success') // Trigger sync await this.backgroundSync() // Refresh stale data await this.refreshStaleData() } private onOffline() { this.showStatus('You\'re offline. Changes will sync when connected.', 'info') } private adaptToConnection() { const connection = navigator.connection if (connection.saveData || connection.effectiveType === 'slow-2g') { // Reduce data usage tf.configure({ quality: 'low', images: false, prefetch: false }) } else if (connection.effectiveType === '4g') { // High quality experience tf.configure({ quality: 'high', images: true, prefetch: true }) } } // Fetch weather with offline support async getWeather(city: string): Promise { try { // Try network first const { data } = await tf.get(`/api/weather/${city}`, { timeout: navigator.onLine ? 10000 : 1000 // Short timeout if offline }) // Cache for offline use await this.cacheWeather(city, data) return data } catch (error) { // Fallback to cache const cached = await this.getCachedWeather(city) if (cached) { // Mark as stale cached.offline = true cached.cachedAt = cached.timestamp return cached } throw new Error('No offline data available') } } private async cacheWeather(city: string, data: WeatherData) { const tx = this.db.transaction('weather', 'readwrite') const store = tx.objectStore('weather') await store.put({ city, data, timestamp: Date.now() }) } private async getCachedWeather(city: string): Promise { const tx = this.db.transaction('weather', 'readonly') const store = tx.objectStore('weather') const result = await store.get(city) return result?.data || null } // Queue user actions for sync async queueAction(action: UserAction) { const tx = this.db.transaction('actions', 'readwrite') const store = tx.objectStore('actions') await store.add({ ...action, timestamp: Date.now(), synced: false }) // Try immediate sync if online if (navigator.onLine) { this.backgroundSync() } } // Background sync private async backgroundSync() { if (this.syncInProgress) return this.syncInProgress = true console.log('Starting background sync...') try { // Get pending actions const tx = this.db.transaction('actions', 'readonly') const store = tx.objectStore('actions') const actions = await store.index('timestamp').getAll() const pending = actions.filter(a => !a.synced) console.log(`Syncing ${pending.length} actions`) for (const action of pending) { try { await this.syncAction(action) } catch (error) { console.error('Failed to sync action:', action, error) } } // Update last sync time await this.updateSyncMetadata({ lastSync: Date.now(), pendingActions: pending.length }) } finally { this.syncInProgress = false } } private async syncAction(action: UserAction) { let response switch (action.type) { case 'ADD_FAVORITE': response = await tf.post('/api/favorites', { data: { city: action.city } }) break case 'REMOVE_FAVORITE': response = await tf.delete(`/api/favorites/${action.city}`) break case 'UPDATE_SETTINGS': response = await tf.patch('/api/settings', { data: action.settings }) break } if (response.ok) { // Mark as synced const tx = this.db.transaction('actions', 'readwrite') const store = tx.objectStore('actions') action.synced = true action.syncedAt = Date.now() await store.put(action) } } private async refreshStaleData() { const tx = this.db.transaction('weather', 'readonly') const store = tx.objectStore('weather') const index = store.index('timestamp') // Get data older than 30 minutes const staleTime = Date.now() - (30 * 60 * 1000) const range = IDBKeyRange.upperBound(staleTime) const staleCities = await index.getAllKeys(range) console.log(`Refreshing ${staleCities.length} stale cities`) // Refresh in background staleCities.forEach(city => { this.getWeather(city).catch(() => {}) }) } private showStatus(message: string, type: 'info' | 'success' | 'error') { // Implementation depends on UI framework console.log(`[${type}] ${message}`) } private async updateSyncMetadata(data: any) { const tx = this.db.transaction('sync', 'readwrite') const store = tx.objectStore('sync') await store.put({ key: 'metadata', ...data }) } } // Progressive Web App configuration class WeatherBuddyPWA { private deferredPrompt: any async init() { // Register service worker await registerServiceWorker() // Setup install prompt this.setupInstallPrompt() // Check if already installed this.checkInstallStatus() // Setup app shortcuts this.setupShortcuts() } private setupInstallPrompt() { window.addEventListener('beforeinstallprompt', (e) => { // Prevent Chrome 67 and earlier from automatically showing the prompt e.preventDefault() // Stash the event so it can be triggered later this.deferredPrompt = e // Show install button this.showInstallButton() }) window.addEventListener('appinstalled', () => { console.log('Weather Buddy installed!') // Track installation tf.post('/api/analytics/install', { data: { timestamp: Date.now(), source: 'pwa' } }).catch(() => {}) }) } private showInstallButton() { const button = document.getElementById('install-button') if (button) { button.style.display = 'block' button.addEventListener('click', async () => { if (!this.deferredPrompt) return // Show prompt this.deferredPrompt.prompt() // Wait for user response const { outcome } = await this.deferredPrompt.userChoice console.log(`User response: ${outcome}`) // Clear prompt this.deferredPrompt = null button.style.display = 'none' }) } } private checkInstallStatus() { if (window.matchMedia('(display-mode: standalone)').matches) { console.log('Running as installed PWA') // Enable PWA features this.enablePWAFeatures() } } private enablePWAFeatures() { // File handling if ('launchQueue' in window) { window.launchQueue.setConsumer(async (params) => { if (!params.files.length) return // Handle shared files for (const file of params.files) { const blob = await file.getFile() await this.handleSharedFile(blob) } }) } // Share target if ('share' in navigator) { // App can receive shared data const params = new URLSearchParams(window.location.search) const sharedTitle = params.get('title') const sharedText = params.get('text') const sharedUrl = params.get('url') if (sharedTitle || sharedText || sharedUrl) { this.handleSharedData({ sharedTitle, sharedText, sharedUrl }) } } } private setupShortcuts() { // Keyboard shortcuts document.addEventListener('keydown', (e) => { if (e.ctrlKey || e.metaKey) { switch (e.key) { case 's': e.preventDefault() this.syncNow() break case 'r': e.preventDefault() this.refreshAll() break } } }) } private async handleSharedFile(file: Blob) { // Handle weather data files if (file.type === 'application/json') { const text = await file.text() const data = JSON.parse(text) if (data.type === 'weather-export') { await this.importWeatherData(data) } } } private handleSharedData(data: any) { // Handle shared locations if (data.sharedText?.includes('weather')) { const city = this.extractCityFromText(data.sharedText) if (city) { this.navigateToCity(city) } } } private async syncNow() { const service = new OfflineWeatherService() await service.backgroundSync() } private async refreshAll() { const cities = this.getAllCities() const service = new OfflineWeatherService() await Promise.all( cities.map(city => service.getWeather(city)) ) } private extractCityFromText(text: string): string | null { // Simple extraction logic const match = text.match(/weather in (\w+)/i) return match ? match[1] : null } private navigateToCity(city: string) { window.location.href = `/city/${city}` } private getAllCities(): string[] { // Get from local storage or state return JSON.parse(localStorage.getItem('cities') || '[]') } private async importWeatherData(data: any) { // Import cities and preferences console.log('Importing weather data:', data) } } // Service Worker strategies class CacheStrategies { // Network First - Fresh data when possible static async networkFirst(request: Request): Promise { try { const response = await fetch(request) // Cache successful responses if (response.ok) { const cache = await caches.open('api-cache') cache.put(request, response.clone()) } return response } catch (error) { // Fallback to cache const cached = await caches.match(request) if (cached) { return cached } throw error } } // Cache First - Speed over freshness static async cacheFirst(request: Request): Promise { const cached = await caches.match(request) if (cached) { // Update cache in background fetch(request).then(response => { if (response.ok) { caches.open('api-cache').then(cache => { cache.put(request, response) }) } }) return cached } // Not in cache, fetch from network const response = await fetch(request) if (response.ok) { const cache = await caches.open('api-cache') cache.put(request, response.clone()) } return response } // Stale While Revalidate static async staleWhileRevalidate(request: Request): Promise { const cached = await caches.match(request) // Always fetch fresh version const fetchPromise = fetch(request).then(response => { if (response.ok) { caches.open('api-cache').then(cache => { cache.put(request, response.clone()) }) // Notify clients of update self.clients.matchAll().then(clients => { clients.forEach(client => { client.postMessage({ type: 'cache-update', url: request.url }) }) }) } return response }) // Return cached immediately if available return cached || fetchPromise } } // Initialize PWA const pwa = new WeatherBuddyPWA() pwa.init() // Initialize offline service const offlineService = new OfflineWeatherService() offlineService.init() // Export for testing export { offlineService, pwa } ``` ## Advanced Offline Patterns ### 1. Conflict Resolution Handle conflicts when syncing offline changes: ```typescript class ConflictResolver { async resolve(local: any, remote: any): Promise { // Strategy 1: Last Write Wins if (local.timestamp > remote.timestamp) { return local } // Strategy 2: Merge if (this.canMerge(local, remote)) { return this.merge(local, remote) } // Strategy 3: User Choice return this.promptUser(local, remote) } private canMerge(local: any, remote: any): boolean { // Check if changes are to different fields const localChanges = Object.keys(local).filter(k => local[k] !== remote[k]) const remoteChanges = Object.keys(remote).filter(k => remote[k] !== local[k]) // No overlapping changes return localChanges.every(k => !remoteChanges.includes(k)) } private merge(local: any, remote: any): any { // Three-way merge with common ancestor const merged = { ...remote } // Apply non-conflicting local changes Object.keys(local).forEach(key => { if (local[key] !== remote[key] && !this.isConflict(key, local, remote)) { merged[key] = local[key] } }) return merged } private async promptUser(local: any, remote: any): Promise { // Show conflict UI return new Promise(resolve => { showConflictDialog({ local, remote, onResolve: resolve }) }) } } ``` ### 2. Selective Offline Cache based on user behavior: ```typescript class SelectiveOfflineCache { private usage = new Map() private maxSize = 50 * 1024 * 1024 // 50MB async cacheIfPopular(url: string, response: Response) { // Track usage const count = (this.usage.get(url) || 0) + 1 this.usage.set(url, count) // Cache if used frequently if (count > 3) { await this.cache(url, response) } } private async cache(url: string, response: Response) { const cache = await caches.open('selective-cache') // Check size limit const size = await this.getCacheSize() if (size > this.maxSize) { await this.evictLRU() } await cache.put(url, response) } private async getCacheSize(): Promise { if ('estimate' in navigator.storage) { const estimate = await navigator.storage.estimate() return estimate.usage || 0 } return 0 } private async evictLRU() { const cache = await caches.open('selective-cache') const requests = await cache.keys() // Sort by last access time const sorted = requests.sort((a, b) => { const aTime = this.getLastAccess(a.url) const bTime = this.getLastAccess(b.url) return aTime - bTime }) // Remove oldest 10% const toRemove = Math.floor(sorted.length * 0.1) for (let i = 0; i < toRemove; i++) { await cache.delete(sorted[i]) } } } ``` ### 3. Progressive Data Loading Load essential data first, details later: ```typescript class ProgressiveLoader { async loadCity(city: string): Promise { // Phase 1: Essential data (name, current temp) const essential = await this.loadEssential(city) this.render(essential) // Phase 2: Extended data (forecast, humidity) const extended = await this.loadExtended(city) this.update(extended) // Phase 3: Rich data (graphs, history) if (navigator.connection?.effectiveType === '4g') { const rich = await this.loadRich(city) this.enhance(rich) } return { essential, extended, rich } } private async loadEssential(city: string): Promise { // Small, critical data const response = await fetch(`/api/weather/${city}/essential`) return response.json() } private async loadExtended(city: string): Promise { // Medium-sized additional data const response = await fetch(`/api/weather/${city}/extended`) return response.json() } private async loadRich(city: string): Promise { // Large, nice-to-have data const response = await fetch(`/api/weather/${city}/rich`) return response.json() } } ``` ### 4. Background Fetch Download large data in the background: ```typescript // In service worker self.addEventListener('backgroundfetch', async (event) => { const id = event.registration.id if (id === 'weather-maps-download') { event.waitUntil( (async () => { const records = await event.registration.matchAll() for (const record of records) { const response = await record.responseReady if (response.ok) { const cache = await caches.open('weather-maps') await cache.put(record.request, response) } } // Notify app const clients = await self.clients.matchAll() clients.forEach(client => { client.postMessage({ type: 'download-complete', id: id }) }) })() ) } }) // In main app async function downloadWeatherMaps() { const registration = await navigator.serviceWorker.ready const bgFetch = await registration.backgroundFetch.fetch( 'weather-maps-download', [ '/maps/radar/current.png', '/maps/satellite/current.png', '/maps/forecast/24h.png', '/maps/forecast/48h.png' ], { title: 'Downloading weather maps', icons: [{ sizes: '192x192', src: '/icon-192.png', type: 'image/png' }], downloadTotal: 10 * 1024 * 1024 // 10MB } ) bgFetch.addEventListener('progress', () => { const percent = Math.round(bgFetch.downloaded / bgFetch.downloadTotal * 100) updateProgress(percent) }) } ``` ## Best Practices for Offline Apps ๐ŸŽฏ ### 1. Design Offline-First ```typescript // Always assume offline async function getData(url: string) { // Check cache first const cached = await getFromCache(url) if (cached && !isStale(cached)) { return cached } // Try network with short timeout try { const fresh = await fetchWithTimeout(url, 3000) await updateCache(url, fresh) return fresh } catch { // Return stale cache if available if (cached) { markAsStale(cached) return cached } throw new Error('No data available') } } ``` ### 2. Clear Offline Indicators ```typescript // Show connection status class ConnectionIndicator { private element: HTMLElement constructor() { this.element = this.createElement() this.updateStatus() window.addEventListener('online', () => this.updateStatus()) window.addEventListener('offline', () => this.updateStatus()) } private updateStatus() { const online = navigator.onLine this.element.className = online ? 'online' : 'offline' this.element.textContent = online ? 'Online' : 'Offline' // Show sync status if (!online) { const pending = this.getPendingCount() if (pending > 0) { this.element.textContent += ` (${pending} pending)` } } } } ``` ### 3. Smart Sync Strategies ```typescript // Sync based on conditions class SmartSync { async sync() { // Don't sync on metered connections if (navigator.connection?.saveData) { return } // Don't sync on battery const battery = await navigator.getBattery() if (battery.level < 0.2 && !battery.charging) { return } // Sync when idle if ('requestIdleCallback' in window) { requestIdleCallback(() => { this.performSync() }, { timeout: 10000 }) } else { this.performSync() } } } ``` ### 4. Handle Storage Limits ```typescript // Monitor and manage storage class StorageManager { async checkQuota() { if ('storage' in navigator && 'estimate' in navigator.storage) { const estimate = await navigator.storage.estimate() const percentUsed = (estimate.usage! / estimate.quota!) * 100 console.log(`Storage: ${percentUsed.toFixed(2)}% used`) if (percentUsed > 80) { await this.cleanup() } } } async requestPersistence() { if ('storage' in navigator && 'persist' in navigator.storage) { const isPersisted = await navigator.storage.persisted() if (!isPersisted) { const result = await navigator.storage.persist() console.log(`Persistence ${result ? 'granted' : 'denied'}`) } } } async cleanup() { // Remove old cache entries const cacheNames = await caches.keys() for (const name of cacheNames) { if (name.includes('v1') && !name.includes('v2')) { await caches.delete(name) } } // Clean IndexedDB await this.cleanOldData() } } ``` ## Practice Time! ๐Ÿ‹๏ธ ### Exercise 1: Build Offline Queue Create a robust offline queue: ```typescript class OfflineQueue { // Your code here: // - Queue failed requests // - Persist to IndexedDB // - Retry with exponential backoff // - Handle conflicts // - Notify on sync } ``` ### Exercise 2: Implement Smart Caching Build intelligent cache management: ```typescript class SmartCache { // Your code here: // - Selective caching // - Size management // - Priority-based eviction // - Update strategies } ``` ### Exercise 3: Create Sync UI Build UI for offline status: ```typescript class SyncUI { // Your code here: // - Connection indicator // - Sync progress // - Conflict resolution // - Manual sync trigger } ``` ## Key Takeaways ๐ŸŽฏ 1. **Service Workers enable offline functionality** - Cache and intercept requests 2. **IndexedDB stores structured offline data** - Better than localStorage 3. **Queue mutations for background sync** - Never lose user changes 4. **Design offline-first** - Assume network will fail 5. **Progressive enhancement based on connection** - Adapt to network quality 6. **Clear offline indicators** - Users need to know status 7. **Handle conflicts gracefully** - Merge or prompt user 8. **Respect device constraints** - Battery, storage, data saving ## Common Pitfalls ๐Ÿšจ 1. **Not handling offline from start** - Retrofit is harder 2. **Unclear sync status** - Users don't know what's happening 3. **No conflict resolution** - Data inconsistency 4. **Ignoring storage limits** - App stops working 5. **Always syncing everything** - Wastes battery/data 6. **No offline content** - Blank screens frustrate users ## What's Next? You've made your app work offline! But how do you test all these features? In Chapter 12, we'll explore testing and debugging: - Unit testing API calls - Integration testing with mocks - E2E testing strategies - Debugging tools - Performance profiling - Error tracking Ready to test like a pro? See you in Chapter 12! ๐Ÿงช --- ## Chapter Summary - Service Workers enable offline functionality by intercepting and caching requests - TypedFetch integrates seamlessly with offline strategies and background sync - IndexedDB provides structured storage for offline data and sync queues - Queue failed mutations and sync when back online to prevent data loss - Progressive Web App features like install prompts and file handling enhance UX - Design offline-first and show clear indicators of connection status - Handle sync conflicts with merge strategies or user intervention - Weather Buddy 11.0 works perfectly offline with automatic sync **Next Chapter Preview**: Testing & Debugging - Unit tests, integration tests, mocking strategies, and powerful debugging tools for TypedFetch applications.