Back to Blog
PWAService WorkersOfflineWeb Development

Progressive Web Apps: Building Offline-First Experiences

Create web apps that work offline and feel native. From service workers to app manifests to push notifications.

B
Bootspring Team
Engineering
July 20, 2024
5 min read

Progressive Web Apps combine the best of web and native apps. They're fast, reliable, and installable—all without app store approval.

PWA Checklist#

Core Requirements: ✓ HTTPS ✓ Service worker ✓ Web app manifest ✓ Responsive design Enhanced Features: ✓ Offline functionality ✓ Push notifications ✓ Background sync ✓ Install prompt

Web App Manifest#

1// manifest.json 2{ 3 "name": "My PWA Application", 4 "short_name": "My PWA", 5 "description": "A progressive web application", 6 "start_url": "/", 7 "display": "standalone", 8 "background_color": "#ffffff", 9 "theme_color": "#3b82f6", 10 "orientation": "portrait-primary", 11 "icons": [ 12 { 13 "src": "/icons/icon-192.png", 14 "sizes": "192x192", 15 "type": "image/png", 16 "purpose": "any maskable" 17 }, 18 { 19 "src": "/icons/icon-512.png", 20 "sizes": "512x512", 21 "type": "image/png" 22 } 23 ], 24 "screenshots": [ 25 { 26 "src": "/screenshots/home.png", 27 "sizes": "1280x720", 28 "type": "image/png", 29 "form_factor": "wide" 30 } 31 ], 32 "shortcuts": [ 33 { 34 "name": "New Order", 35 "url": "/orders/new", 36 "icons": [{ "src": "/icons/new-order.png", "sizes": "96x96" }] 37 } 38 ] 39}
<!-- Link in HTML --> <link rel="manifest" href="/manifest.json"> <meta name="theme-color" content="#3b82f6"> <link rel="apple-touch-icon" href="/icons/icon-192.png">

Service Workers#

Basic Registration#

1// Register service worker 2if ('serviceWorker' in navigator) { 3 window.addEventListener('load', async () => { 4 try { 5 const registration = await navigator.serviceWorker.register('/sw.js'); 6 console.log('SW registered:', registration.scope); 7 } catch (error) { 8 console.error('SW registration failed:', error); 9 } 10 }); 11}

Service Worker Implementation#

1// sw.js 2const CACHE_NAME = 'my-pwa-v1'; 3const STATIC_ASSETS = [ 4 '/', 5 '/index.html', 6 '/styles.css', 7 '/app.js', 8 '/icons/icon-192.png', 9]; 10 11// Install: Cache static assets 12self.addEventListener('install', (event) => { 13 event.waitUntil( 14 caches.open(CACHE_NAME).then((cache) => { 15 return cache.addAll(STATIC_ASSETS); 16 }) 17 ); 18 self.skipWaiting(); 19}); 20 21// Activate: Clean old caches 22self.addEventListener('activate', (event) => { 23 event.waitUntil( 24 caches.keys().then((keys) => { 25 return Promise.all( 26 keys 27 .filter((key) => key !== CACHE_NAME) 28 .map((key) => caches.delete(key)) 29 ); 30 }) 31 ); 32 self.clients.claim(); 33}); 34 35// Fetch: Serve from cache, fallback to network 36self.addEventListener('fetch', (event) => { 37 event.respondWith( 38 caches.match(event.request).then((cached) => { 39 if (cached) { 40 return cached; 41 } 42 43 return fetch(event.request).then((response) => { 44 // Cache successful responses 45 if (response.ok) { 46 const clone = response.clone(); 47 caches.open(CACHE_NAME).then((cache) => { 48 cache.put(event.request, clone); 49 }); 50 } 51 return response; 52 }); 53 }) 54 ); 55});

Caching Strategies#

Cache First#

1// Best for: Static assets, fonts, images 2async function cacheFirst(request: Request): Promise<Response> { 3 const cached = await caches.match(request); 4 if (cached) return cached; 5 6 const response = await fetch(request); 7 const cache = await caches.open(CACHE_NAME); 8 cache.put(request, response.clone()); 9 return response; 10}

Network First#

1// Best for: API calls, frequently updated content 2async function networkFirst(request: Request): Promise<Response> { 3 try { 4 const response = await fetch(request); 5 const cache = await caches.open(CACHE_NAME); 6 cache.put(request, response.clone()); 7 return response; 8 } catch { 9 const cached = await caches.match(request); 10 if (cached) return cached; 11 throw new Error('No cached response'); 12 } 13}

Stale While Revalidate#

1// Best for: Avatars, non-critical images 2async function staleWhileRevalidate(request: Request): Promise<Response> { 3 const cache = await caches.open(CACHE_NAME); 4 const cached = await cache.match(request); 5 6 const fetchPromise = fetch(request).then((response) => { 7 cache.put(request, response.clone()); 8 return response; 9 }); 10 11 return cached || fetchPromise; 12}

Install Prompt#

1// Handle install prompt 2let deferredPrompt: BeforeInstallPromptEvent | null = null; 3 4window.addEventListener('beforeinstallprompt', (e) => { 5 e.preventDefault(); 6 deferredPrompt = e; 7 showInstallButton(); 8}); 9 10async function installApp() { 11 if (!deferredPrompt) return; 12 13 deferredPrompt.prompt(); 14 const { outcome } = await deferredPrompt.userChoice; 15 16 if (outcome === 'accepted') { 17 console.log('User installed the app'); 18 } 19 20 deferredPrompt = null; 21 hideInstallButton(); 22} 23 24window.addEventListener('appinstalled', () => { 25 console.log('App was installed'); 26 hideInstallButton(); 27});

Push Notifications#

Request Permission#

1async function requestNotificationPermission(): Promise<boolean> { 2 if (!('Notification' in window)) return false; 3 4 const permission = await Notification.requestPermission(); 5 return permission === 'granted'; 6}

Subscribe to Push#

1async function subscribeToPush(): Promise<PushSubscription | null> { 2 const registration = await navigator.serviceWorker.ready; 3 4 const subscription = await registration.pushManager.subscribe({ 5 userVisibleOnly: true, 6 applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY), 7 }); 8 9 // Send subscription to server 10 await fetch('/api/push/subscribe', { 11 method: 'POST', 12 body: JSON.stringify(subscription), 13 headers: { 'Content-Type': 'application/json' }, 14 }); 15 16 return subscription; 17}

Handle Push in Service Worker#

1// sw.js 2self.addEventListener('push', (event) => { 3 const data = event.data?.json() || {}; 4 5 event.waitUntil( 6 self.registration.showNotification(data.title, { 7 body: data.body, 8 icon: '/icons/icon-192.png', 9 badge: '/icons/badge.png', 10 data: data.url, 11 actions: [ 12 { action: 'view', title: 'View' }, 13 { action: 'dismiss', title: 'Dismiss' }, 14 ], 15 }) 16 ); 17}); 18 19self.addEventListener('notificationclick', (event) => { 20 event.notification.close(); 21 22 if (event.action === 'view' || !event.action) { 23 event.waitUntil( 24 clients.openWindow(event.notification.data || '/') 25 ); 26 } 27});

Background Sync#

1// Register sync 2async function saveForLater(data: any): Promise<void> { 3 const db = await openDB(); 4 await db.put('pending', data); 5 6 const registration = await navigator.serviceWorker.ready; 7 await registration.sync.register('sync-data'); 8} 9 10// Handle in service worker 11self.addEventListener('sync', (event) => { 12 if (event.tag === 'sync-data') { 13 event.waitUntil(syncPendingData()); 14 } 15}); 16 17async function syncPendingData(): Promise<void> { 18 const db = await openDB(); 19 const pending = await db.getAll('pending'); 20 21 for (const item of pending) { 22 try { 23 await fetch('/api/data', { 24 method: 'POST', 25 body: JSON.stringify(item), 26 }); 27 await db.delete('pending', item.id); 28 } catch { 29 // Will retry on next sync 30 } 31 } 32}

Workbox#

1// Simplify service worker with Workbox 2import { precacheAndRoute } from 'workbox-precaching'; 3import { registerRoute } from 'workbox-routing'; 4import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies'; 5 6// Precache static assets 7precacheAndRoute(self.__WB_MANIFEST); 8 9// Cache images 10registerRoute( 11 ({ request }) => request.destination === 'image', 12 new CacheFirst({ cacheName: 'images' }) 13); 14 15// API calls 16registerRoute( 17 ({ url }) => url.pathname.startsWith('/api'), 18 new NetworkFirst({ cacheName: 'api' }) 19); 20 21// Fonts 22registerRoute( 23 ({ url }) => url.origin === 'https://fonts.googleapis.com', 24 new StaleWhileRevalidate({ cacheName: 'fonts' }) 25);

Conclusion#

PWAs offer native-like experiences with web technology. Start with the manifest and basic service worker, then add offline support and push notifications as needed.

The key is progressive enhancement—the app works everywhere but shines when features are available.

Share this article

Help spread the word about Bootspring