TypeFetched/manual/chapter-11-offline-pwa.md
Casey Collier b85b9a63e2 Initial commit: TypedFetch - Zero-dependency, type-safe HTTP client
Features:
- Zero configuration, just works out of the box
- Runtime type inference and validation
- Built-in caching with W-TinyLFU algorithm
- Automatic retries with exponential backoff
- Circuit breaker for resilience
- Request deduplication
- Offline support with queue
- OpenAPI schema discovery
- Full TypeScript support with type descriptors
- Modular architecture
- Configurable for advanced use cases

Built with bun, ready for npm publishing
2025-07-20 12:35:43 -04:00

32 KiB

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:

// 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:

// 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:

// 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:

// 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<IDBDatabase> {
    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<WeatherData> {
    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<WeatherData | null> {
    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<Response> {
    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<Response> {
    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<Response> {
    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:

class ConflictResolver {
  async resolve(local: any, remote: any): Promise<any> {
    // 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<any> {
    // Show conflict UI
    return new Promise(resolve => {
      showConflictDialog({
        local,
        remote,
        onResolve: resolve
      })
    })
  }
}

2. Selective Offline

Cache based on user behavior:

class SelectiveOfflineCache {
  private usage = new Map<string, number>()
  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<number> {
    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:

class ProgressiveLoader {
  async loadCity(city: string): Promise<CityData> {
    // 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<EssentialData> {
    // Small, critical data
    const response = await fetch(`/api/weather/${city}/essential`)
    return response.json()
  }
  
  private async loadExtended(city: string): Promise<ExtendedData> {
    // Medium-sized additional data
    const response = await fetch(`/api/weather/${city}/extended`)
    return response.json()
  }
  
  private async loadRich(city: string): Promise<RichData> {
    // 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:

// 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

// 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

// 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

// 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

// 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:

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:

class SmartCache {
  // Your code here:
  // - Selective caching
  // - Size management
  // - Priority-based eviction
  // - Update strategies
}

Exercise 3: Create Sync UI

Build UI for offline status:

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.