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
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 🎯
- Service Workers enable offline functionality - Cache and intercept requests
- IndexedDB stores structured offline data - Better than localStorage
- Queue mutations for background sync - Never lose user changes
- Design offline-first - Assume network will fail
- Progressive enhancement based on connection - Adapt to network quality
- Clear offline indicators - Users need to know status
- Handle conflicts gracefully - Merge or prompt user
- Respect device constraints - Battery, storage, data saving
Common Pitfalls 🚨
- Not handling offline from start - Retrofit is harder
- Unclear sync status - Users don't know what's happening
- No conflict resolution - Data inconsistency
- Ignoring storage limits - App stops working
- Always syncing everything - Wastes battery/data
- 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.