Progressive Web Apps
Progressive Web Apps (PWAs) are web applications that use modern browser APIs and progressive enhancement strategies to deliver native app-like experiences to users. They are installable, work offline, and can access device features — all without requiring an app store.
A PWA is not a single technology but a set of patterns and best practices built on three pillars: a Web App Manifest for installability, a Service Worker for offline capability and network control, and HTTPS for security. Together they enable push notifications, background sync, and cached asset delivery.
The Web App Manifest is a JSON file that tells the browser how the application should behave when installed on a device. It defines the app name, icons, theme colors, display mode, and start URL. Without a valid manifest the browser will not offer the install prompt.
| 1 | { |
| 2 | "name": "My Progressive App", |
| 3 | "short_name": "MyApp", |
| 4 | "description": "A progressive web app with offline support", |
| 5 | "start_url": "/", |
| 6 | "display": "standalone", |
| 7 | "background_color": "#0A0A0A", |
| 8 | "theme_color": "#00FF41", |
| 9 | "orientation": "any", |
| 10 | "scope": "/", |
| 11 | "lang": "en", |
| 12 | "categories": ["productivity", "utilities"], |
| 13 | "icons": [ |
| 14 | { |
| 15 | "src": "/icons/icon-72.png", |
| 16 | "sizes": "72x72", |
| 17 | "type": "image/png" |
| 18 | }, |
| 19 | { |
| 20 | "src": "/icons/icon-96.png", |
| 21 | "sizes": "96x96", |
| 22 | "type": "image/png" |
| 23 | }, |
| 24 | { |
| 25 | "src": "/icons/icon-128.png", |
| 26 | "sizes": "128x128", |
| 27 | "type": "image/png" |
| 28 | }, |
| 29 | { |
| 30 | "src": "/icons/icon-144.png", |
| 31 | "sizes": "144x144", |
| 32 | "type": "image/png" |
| 33 | }, |
| 34 | { |
| 35 | "src": "/icons/icon-192.png", |
| 36 | "sizes": "192x192", |
| 37 | "type": "image/png", |
| 38 | "purpose": "any maskable" |
| 39 | }, |
| 40 | { |
| 41 | "src": "/icons/icon-512.png", |
| 42 | "sizes": "512x512", |
| 43 | "type": "image/png" |
| 44 | } |
| 45 | ], |
| 46 | "screenshots": [ |
| 47 | { |
| 48 | "src": "/screenshots/desktop.png", |
| 49 | "sizes": "1280x720", |
| 50 | "type": "image/png", |
| 51 | "form_factor": "wide", |
| 52 | "label": "Desktop view" |
| 53 | }, |
| 54 | { |
| 55 | "src": "/screenshots/mobile.png", |
| 56 | "sizes": "750x1334", |
| 57 | "type": "image/png", |
| 58 | "form_factor": "narrow", |
| 59 | "label": "Mobile view" |
| 60 | } |
| 61 | ], |
| 62 | "shortcuts": [ |
| 63 | { |
| 64 | "name": "New Task", |
| 65 | "short_name": "New", |
| 66 | "url": "/new", |
| 67 | "icons": [{ "src": "/icons/new-96.png", "sizes": "96x96" }] |
| 68 | } |
| 69 | ] |
| 70 | } |
info
A Service Worker is a script that runs in the background of the browser, separate from the main thread. It intercepts network requests, caches responses, and enables offline functionality. Service Workers are the backbone of any PWA and require HTTPS.
| 1 | // sw.js — Service Worker registration from main thread |
| 2 | if ('serviceWorker' in navigator) { |
| 3 | window.addEventListener('load', async () => { |
| 4 | try { |
| 5 | const registration = await navigator.serviceWorker.register('/sw.js', { |
| 6 | scope: '/', |
| 7 | }); |
| 8 | console.log('SW registered:', registration.scope); |
| 9 | |
| 10 | registration.addEventListener('updatefound', () => { |
| 11 | const newWorker = registration.installing; |
| 12 | newWorker.addEventListener('statechange', () => { |
| 13 | if (newWorker.state === 'activated') { |
| 14 | notifyUser('App updated! Refresh for latest version.'); |
| 15 | } |
| 16 | }); |
| 17 | }); |
| 18 | } catch (err) { |
| 19 | console.error('SW registration failed:', err); |
| 20 | } |
| 21 | }); |
| 22 | } |
| 23 | |
| 24 | // sw.js — The service worker itself |
| 25 | const CACHE_NAME = 'app-cache-v1'; |
| 26 | const STATIC_ASSETS = [ |
| 27 | '/', |
| 28 | '/index.html', |
| 29 | '/app.js', |
| 30 | '/styles.css', |
| 31 | '/offline.html', |
| 32 | ]; |
| 33 | |
| 34 | // Install — pre-cache critical assets |
| 35 | self.addEventListener('install', (event) => { |
| 36 | event.waitUntil( |
| 37 | caches.open(CACHE_NAME).then((cache) => { |
| 38 | return cache.addAll(STATIC_ASSETS); |
| 39 | }) |
| 40 | ); |
| 41 | self.skipWaiting(); |
| 42 | }); |
| 43 | |
| 44 | // Activate — clean old caches |
| 45 | self.addEventListener('activate', (event) => { |
| 46 | event.waitUntil( |
| 47 | caches.keys().then((cacheNames) => { |
| 48 | return Promise.all( |
| 49 | cacheNames |
| 50 | .filter((name) => name !== CACHE_NAME) |
| 51 | .map((name) => caches.delete(name)) |
| 52 | ); |
| 53 | }) |
| 54 | ); |
| 55 | self.clients.claim(); |
| 56 | }); |
| 57 | |
| 58 | // Fetch — intercept all network requests |
| 59 | self.addEventListener('fetch', (event) => { |
| 60 | event.respondWith( |
| 61 | caches.match(event.request).then((cached) => { |
| 62 | return cached || fetch(event.request).then((response) => { |
| 63 | // Don't cache non-GET or opaque responses |
| 64 | if (event.request.method !== 'GET' || response.type === 'opaque') { |
| 65 | return response; |
| 66 | } |
| 67 | const clone = response.clone(); |
| 68 | caches.open(CACHE_NAME).then((cache) => { |
| 69 | cache.put(event.request, clone); |
| 70 | }); |
| 71 | return response; |
| 72 | }); |
| 73 | }).catch(() => { |
| 74 | // Fallback for offline |
| 75 | if (event.request.destination === 'document') { |
| 76 | return caches.match('/offline.html'); |
| 77 | } |
| 78 | }) |
| 79 | ); |
| 80 | }); |
warning
Choosing the right caching strategy determines how your PWA behaves when offline, how fresh content stays, and how fast the app loads. Each strategy trades off freshness, speed, and bandwidth usage differently.
| 1 | // Strategy 1: Cache First (best for static assets) |
| 2 | async function cacheFirst(request) { |
| 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('static-v1'); |
| 8 | cache.put(request, response.clone()); |
| 9 | return response; |
| 10 | } |
| 11 | |
| 12 | // Strategy 2: Network First (best for API data) |
| 13 | async function networkFirst(request) { |
| 14 | try { |
| 15 | const response = await fetch(request); |
| 16 | const cache = await caches.open('api-v1'); |
| 17 | cache.put(request, response.clone()); |
| 18 | return response; |
| 19 | } catch { |
| 20 | const cached = await caches.match(request); |
| 21 | return cached || new Response('{"error":"offline"}', { |
| 22 | headers: { 'Content-Type': 'application/json' }, |
| 23 | }); |
| 24 | } |
| 25 | } |
| 26 | |
| 27 | // Strategy 3: Stale While Revalidate (best for semi-dynamic) |
| 28 | async function staleWhileRevalidate(request) { |
| 29 | const cache = await caches.open('hybrid-v1'); |
| 30 | const cached = await cache.match(request); |
| 31 | |
| 32 | const fetchPromise = fetch(request).then((response) => { |
| 33 | cache.put(request, response.clone()); |
| 34 | return response; |
| 35 | }).catch(() => cached); |
| 36 | |
| 37 | return cached || fetchPromise; |
| 38 | } |
| 39 | |
| 40 | // Strategy 4: Network Only (for non-cacheable requests) |
| 41 | async function networkOnly(request) { |
| 42 | return fetch(request); |
| 43 | } |
| 44 | |
| 45 | // Strategy 5: Cache Only (for pre-cached assets) |
| 46 | async function cacheOnly(request) { |
| 47 | return caches.match(request); |
| 48 | } |
| 49 | |
| 50 | // Router — apply different strategies per request type |
| 51 | self.addEventListener('fetch', (event) => { |
| 52 | const url = new URL(event.request.url); |
| 53 | |
| 54 | if (event.request.method !== 'GET') return; |
| 55 | |
| 56 | // Static assets — cache first |
| 57 | if (url.pathname.match(/\.(js|css|png|jpg|svg|woff2)$/)) { |
| 58 | event.respondWith(cacheFirst(event.request)); |
| 59 | return; |
| 60 | } |
| 61 | |
| 62 | // API calls — network first |
| 63 | if (url.pathname.startsWith('/api/')) { |
| 64 | event.respondWith(networkFirst(event.request)); |
| 65 | return; |
| 66 | } |
| 67 | |
| 68 | // HTML pages — stale while revalidate |
| 69 | if (event.request.headers.get('accept')?.includes('text/html')) { |
| 70 | event.respondWith(staleWhileRevalidate(event.request)); |
| 71 | return; |
| 72 | } |
| 73 | |
| 74 | event.respondWith(networkOnly(event.request)); |
| 75 | }); |
best practice
True offline support means the app remains functional even without a network connection. This requires pre-caching critical assets, handling failed fetches gracefully, and providing meaningful offline UI states.
| 1 | // Offline page fallback |
| 2 | const OFFLINE_PAGE = '/offline.html'; |
| 3 | |
| 4 | self.addEventListener('fetch', (event) => { |
| 5 | event.respondWith( |
| 6 | fetch(event.request).catch(async () => { |
| 7 | // Check if this is a navigation request |
| 8 | if (event.request.mode === 'navigate') { |
| 9 | const cache = await caches.open('pages-v1'); |
| 10 | const offlinePage = await cache.match(OFFLINE_PAGE); |
| 11 | return offlinePage || new Response(offlineHTML(), { |
| 12 | headers: { 'Content-Type': 'text/html' }, |
| 13 | }); |
| 14 | } |
| 15 | return new Response('Offline', { status: 503 }); |
| 16 | }) |
| 17 | ); |
| 18 | }); |
| 19 | |
| 20 | // Background Sync — queue actions when offline |
| 21 | self.addEventListener('sync', (event) => { |
| 22 | if (event.tag === 'sync-notes') { |
| 23 | event.waitUntil(syncPendingNotes()); |
| 24 | } |
| 25 | }); |
| 26 | |
| 27 | async function syncPendingNotes() { |
| 28 | const db = await openDB(); |
| 29 | const tx = db.transaction('pending', 'readwrite'); |
| 30 | const store = tx.objectStore('pending'); |
| 31 | const request = store.getAll(); |
| 32 | |
| 33 | return new Promise((resolve, reject) => { |
| 34 | request.onsuccess = async () => { |
| 35 | const notes = request.result; |
| 36 | for (const note of notes) { |
| 37 | try { |
| 38 | await fetch('/api/notes', { |
| 39 | method: 'POST', |
| 40 | headers: { 'Content-Type': 'application/json' }, |
| 41 | body: JSON.stringify(note), |
| 42 | }); |
| 43 | store.delete(note.id); |
| 44 | } catch { |
| 45 | // Will retry on next sync |
| 46 | } |
| 47 | } |
| 48 | resolve(); |
| 49 | }; |
| 50 | request.onerror = reject; |
| 51 | }); |
| 52 | } |
| 53 | |
| 54 | // Queue offline actions from the main thread |
| 55 | async function queueOfflineAction(action) { |
| 56 | const db = await openDB(); |
| 57 | const tx = db.transaction('pending', 'readwrite'); |
| 58 | tx.objectStore('pending').put(action); |
| 59 | |
| 60 | // Register sync event |
| 61 | const registration = await navigator.serviceWorker.ready; |
| 62 | await registration.sync.register('sync-notes'); |
| 63 | } |
| 64 | |
| 65 | // Online/Offline status detection |
| 66 | window.addEventListener('online', () => { |
| 67 | document.body.classList.remove('offline'); |
| 68 | showToast('You are back online'); |
| 69 | }); |
| 70 | |
| 71 | window.addEventListener('offline', () => { |
| 72 | document.body.classList.add('offline'); |
| 73 | showToast('You are offline — changes will sync later'); |
| 74 | }); |
| 75 | |
| 76 | // Check connectivity proactively |
| 77 | function checkConnection() { |
| 78 | return navigator.onLine && fetch('/api/ping', { |
| 79 | method: 'HEAD', |
| 80 | cache: 'no-store', |
| 81 | }).then(() => true).catch(() => false); |
| 82 | } |
info
Service Workers follow a strict lifecycle: registration, installation, activation, and idle. Understanding this flow is critical for handling updates and cache invalidation correctly.
| 1 | // Versioned caching for seamless updates |
| 2 | const CACHE_VERSION = 2; |
| 3 | const CACHE_NAME = `app-cache-v${CACHE_VERSION}`; |
| 4 | |
| 5 | self.addEventListener('install', (event) => { |
| 6 | event.waitUntil( |
| 7 | caches.open(CACHE_NAME).then(async (cache) => { |
| 8 | // Add new assets for this version |
| 9 | await cache.addAll([ |
| 10 | '/', |
| 11 | '/index.html', |
| 12 | '/app.v2.js', |
| 13 | '/styles.v2.css', |
| 14 | ]); |
| 15 | }) |
| 16 | ); |
| 17 | self.skipWaiting(); // Skip waiting to activate immediately |
| 18 | }); |
| 19 | |
| 20 | self.addEventListener('activate', (event) => { |
| 21 | event.waitUntil( |
| 22 | caches.keys().then((names) => |
| 23 | Promise.all( |
| 24 | names |
| 25 | .filter((name) => { |
| 26 | // Parse version number from cache name |
| 27 | const match = name.match(/v(\d+)$/); |
| 28 | if (!match) return true; |
| 29 | return parseInt(match[1]) < CACHE_VERSION; |
| 30 | }) |
| 31 | .map((name) => caches.delete(name)) |
| 32 | ) |
| 33 | ) |
| 34 | ); |
| 35 | self.clients.claim(); // Take control of all open pages |
| 36 | }); |
| 37 | |
| 38 | // Notify clients when update is ready |
| 39 | self.addEventListener('activate', (event) => { |
| 40 | event.waitUntil( |
| 41 | self.clients.matchAll().then((clients) => { |
| 42 | clients.forEach((client) => { |
| 43 | client.postMessage({ |
| 44 | type: 'SW_UPDATED', |
| 45 | version: CACHE_VERSION, |
| 46 | }); |
| 47 | }); |
| 48 | }) |
| 49 | ); |
| 50 | }); |
| 51 | |
| 52 | // Main thread — handle update notifications |
| 53 | navigator.serviceWorker.addEventListener('message', (event) => { |
| 54 | if (event.data.type === 'SW_UPDATED') { |
| 55 | showUpdateBanner(event.data.version); |
| 56 | } |
| 57 | }); |
| 58 | |
| 59 | function showUpdateBanner(version) { |
| 60 | const banner = document.createElement('div'); |
| 61 | banner.className = 'update-banner'; |
| 62 | banner.innerHTML = ` |
| 63 | <p>New version (v${version}) available!</p> |
| 64 | <button onclick="reloadApp()">Refresh</button> |
| 65 | `; |
| 66 | document.body.prepend(banner); |
| 67 | } |
| 68 | |
| 69 | function reloadApp() { |
| 70 | window.location.reload(); |
| 71 | } |
best practice
Push notifications allow PWAs to re-engage users with timely messages even when the app is not open. They require user permission and a push subscription tied to your service worker.
| 1 | // Main thread — request permission and subscribe |
| 2 | async function setupPushNotifications() { |
| 3 | // Check if push is supported |
| 4 | if (!('Notification' in window) || !('serviceWorker' in navigator)) { |
| 5 | console.log('Push notifications not supported'); |
| 6 | return; |
| 7 | } |
| 8 | |
| 9 | // Request permission |
| 10 | const permission = await Notification.requestPermission(); |
| 11 | if (permission !== 'granted') { |
| 12 | console.log('Notification permission denied'); |
| 13 | return; |
| 14 | } |
| 15 | |
| 16 | // Get service worker registration |
| 17 | const registration = await navigator.serviceWorker.ready; |
| 18 | |
| 19 | // Get existing subscription or create new one |
| 20 | let subscription = await registration.pushManager.getSubscription(); |
| 21 | if (!subscription) { |
| 22 | const vapidPublicKey = 'YOUR_VAPID_PUBLIC_KEY'; |
| 23 | subscription = await registration.pushManager.subscribe({ |
| 24 | userVisibleOnly: true, |
| 25 | applicationServerKey: urlBase64ToUint8Array(vapidPublicKey), |
| 26 | }); |
| 27 | } |
| 28 | |
| 29 | // Send subscription to your server |
| 30 | await fetch('/api/push/subscribe', { |
| 31 | method: 'POST', |
| 32 | headers: { 'Content-Type': 'application/json' }, |
| 33 | body: JSON.stringify(subscription), |
| 34 | }); |
| 35 | |
| 36 | console.log('Push subscription active'); |
| 37 | } |
| 38 | |
| 39 | // Convert VAPID key for the browser |
| 40 | function urlBase64ToUint8Array(base64String) { |
| 41 | const padding = '='.repeat((4 - (base64String.length % 4)) % 4); |
| 42 | const base64 = (base64String + padding) |
| 43 | .replace(/-/g, '+') |
| 44 | .replace(/_/g, '/'); |
| 45 | const rawData = atob(base64); |
| 46 | return Uint8Array.from([...rawData].map((c) => c.charCodeAt(0))); |
| 47 | } |
| 48 | |
| 49 | // Service worker — handle incoming push events |
| 50 | self.addEventListener('push', (event) => { |
| 51 | const data = event.data?.json() ?? { |
| 52 | title: 'New Notification', |
| 53 | body: 'You have a new message', |
| 54 | icon: '/icons/icon-192.png', |
| 55 | badge: '/icons/badge-72.png', |
| 56 | data: { url: '/' }, |
| 57 | }; |
| 58 | |
| 59 | event.waitUntil( |
| 60 | self.registration.showNotification(data.title, { |
| 61 | body: data.body, |
| 62 | icon: data.icon, |
| 63 | badge: data.badge, |
| 64 | vibrate: [100, 50, 100], |
| 65 | data: data.data, |
| 66 | actions: [ |
| 67 | { action: 'open', title: 'Open' }, |
| 68 | { action: 'dismiss', title: 'Dismiss' }, |
| 69 | ], |
| 70 | }) |
| 71 | ); |
| 72 | }); |
| 73 | |
| 74 | // Handle notification clicks |
| 75 | self.addEventListener('notificationclick', (event) => { |
| 76 | event.notification.close(); |
| 77 | if (event.action === 'dismiss') return; |
| 78 | |
| 79 | event.waitUntil( |
| 80 | self.clients.matchAll({ type: 'window' }).then((clients) => { |
| 81 | // Focus existing window or open new one |
| 82 | for (const client of clients) { |
| 83 | if (client.url === event.notification.data.url && 'focus' in client) { |
| 84 | return client.focus(); |
| 85 | } |
| 86 | } |
| 87 | return self.clients.openWindow(event.notification.data.url); |
| 88 | }) |
| 89 | ); |
| 90 | }); |
warning
Browsers show an install prompt when the PWA meets criteria: valid manifest, registered service worker, served over HTTPS. You can intercept the prompt with the BeforeInstallPrompt event to customize the install experience.
| 1 | // Capture the browser's install prompt |
| 2 | let deferredPrompt; |
| 3 | |
| 4 | window.addEventListener('beforeinstallprompt', (event) => { |
| 5 | // Prevent the default mini-infobar |
| 6 | event.preventDefault(); |
| 7 | deferredPrompt = event; |
| 8 | |
| 9 | // Show custom install button |
| 10 | const installBtn = document.getElementById('install-btn'); |
| 11 | installBtn.style.display = 'block'; |
| 12 | installBtn.addEventListener('click', installApp); |
| 13 | }); |
| 14 | |
| 15 | async function installApp() { |
| 16 | if (!deferredPrompt) return; |
| 17 | |
| 18 | // Show the install prompt |
| 19 | deferredPrompt.prompt(); |
| 20 | const { outcome } = await deferredPrompt.userChoice; |
| 21 | |
| 22 | if (outcome === 'accepted') { |
| 23 | console.log('User accepted the install prompt'); |
| 24 | } else { |
| 25 | console.log('User dismissed the install prompt'); |
| 26 | } |
| 27 | |
| 28 | deferredPrompt = null; |
| 29 | document.getElementById('install-btn').style.display = 'none'; |
| 30 | } |
| 31 | |
| 32 | // Detect if app is already installed |
| 33 | window.addEventListener('appinstalled', (event) => { |
| 34 | console.log('PWA was installed'); |
| 35 | deferredPrompt = null; |
| 36 | trackInstallation(); |
| 37 | }); |
| 38 | |
| 39 | // Check standalone mode (running as installed app) |
| 40 | function isStandalone() { |
| 41 | return window.matchMedia('(display-mode: standalone)').matches |
| 42 | || window.navigator.standalone === true; |
| 43 | } |
| 44 | |
| 45 | if (isStandalone()) { |
| 46 | console.log('Running as installed PWA'); |
| 47 | // Adjust UI for standalone mode |
| 48 | document.body.classList.add('standalone'); |
| 49 | } |
info
Workbox is a library from Google that simplifies service worker development. It provides pre-built caching strategies, routing, and tools for precaching — removing the need to write service workers from scratch.
| 1 | // workbox-config.js |
| 2 | module.exports = { |
| 3 | globDirectory: 'build/', |
| 4 | globPatterns: ['**/*.{js,css,html,png,svg,woff2}'], |
| 5 | swDest: 'build/sw.js', |
| 6 | runtimeCaching: [ |
| 7 | { |
| 8 | urlPattern: /^https://api\.example\.com\/v1\//, |
| 9 | handler: 'NetworkFirst', |
| 10 | options: { |
| 11 | cacheName: 'api-cache', |
| 12 | expiration: { |
| 13 | maxEntries: 50, |
| 14 | maxAgeSeconds: 300, // 5 minutes |
| 15 | }, |
| 16 | networkTimeoutSeconds: 3, |
| 17 | }, |
| 18 | }, |
| 19 | { |
| 20 | urlPattern: /\.(png|jpg|jpeg|svg|gif|webp)$/, |
| 21 | handler: 'CacheFirst', |
| 22 | options: { |
| 23 | cacheName: 'images', |
| 24 | expiration: { |
| 25 | maxEntries: 100, |
| 26 | maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days |
| 27 | }, |
| 28 | }, |
| 29 | }, |
| 30 | ], |
| 31 | }; |
| 32 | |
| 33 | // sw.js using Workbox |
| 34 | import { precacheAndRoute } from 'workbox-precaching'; |
| 35 | import { registerRoute } from 'workbox-routing'; |
| 36 | import { |
| 37 | CacheFirst, |
| 38 | NetworkFirst, |
| 39 | StaleWhileRevalidate, |
| 40 | } from 'workbox-strategies'; |
| 41 | import { ExpirationPlugin } from 'workbox-expiration'; |
| 42 | import { CacheableResponsePlugin } from 'workbox-cacheable-response'; |
| 43 | |
| 44 | // Precache all build assets |
| 45 | precacheAndRoute(self.__WB_MANIFEST); |
| 46 | |
| 47 | // Cache API responses with network first |
| 48 | registerRoute( |
| 49 | ({ url }) => url.pathname.startsWith('/api/'), |
| 50 | new NetworkFirst({ |
| 51 | cacheName: 'api', |
| 52 | plugins: [ |
| 53 | new ExpirationPlugin({ maxEntries: 50 }), |
| 54 | new CacheableResponsePlugin({ statuses: [0, 200] }), |
| 55 | ], |
| 56 | }) |
| 57 | ); |
| 58 | |
| 59 | // Cache static assets with cache first |
| 60 | registerRoute( |
| 61 | ({ request }) => |
| 62 | request.destination === 'image' || |
| 63 | request.destination === 'font', |
| 64 | new CacheFirst({ |
| 65 | cacheName: 'static-assets', |
| 66 | plugins: [ |
| 67 | new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 30 * 24 * 3600 }), |
| 68 | ], |
| 69 | }) |
| 70 | ); |
| 71 | |
| 72 | // HTML pages with stale while revalidate |
| 73 | registerRoute( |
| 74 | ({ request }) => request.destination === 'document', |
| 75 | new StaleWhileRevalidate({ cacheName: 'pages' }) |
| 76 | ); |
pro tip
Chrome DevTools provides dedicated panels for debugging PWAs. The Application tab shows service worker status, cache contents, manifest details, and push subscription state. Lighthouse audits score your PWA on installability, performance, and best practices.
| 1 | // Debug service worker updates in development |
| 2 | if (location.hostname === 'localhost') { |
| 3 | // Unregister old service workers |
| 4 | navigator.serviceWorker.getRegistrations().then((registrations) => { |
| 5 | registrations.forEach((reg) => { |
| 6 | console.log('Unregistering:', reg.scope); |
| 7 | reg.unregister(); |
| 8 | }); |
| 9 | }); |
| 10 | |
| 11 | // Clear all caches |
| 12 | caches.keys().then((names) => { |
| 13 | names.forEach((name) => { |
| 14 | console.log('Deleting cache:', name); |
| 15 | caches.delete(name); |
| 16 | }); |
| 17 | }); |
| 18 | } |
| 19 | |
| 20 | // Lighthouse PWA audit checklist |
| 21 | // 1. HTTPS enabled |
| 22 | // 2. Valid web app manifest |
| 23 | // 3. Service worker registered |
| 24 | // 4. Page responds with 200 when offline |
| 25 | // 5. Content sized correctly for viewport |
| 26 | // 6. manifest.json has valid icons |
| 27 | // 7. viewport meta tag present |
| 28 | // 8. 200 offline-capable for start_url |
| 29 | |
| 30 | // Check PWA criteria programmatically |
| 31 | async function auditPWA() { |
| 32 | const results = {}; |
| 33 | |
| 34 | // HTTPS check |
| 35 | results.https = location.protocol === 'https:'; |
| 36 | |
| 37 | // Service worker check |
| 38 | const reg = await navigator.serviceWorker?.getRegistration(); |
| 39 | results.serviceWorker = !!reg?.active; |
| 40 | |
| 41 | // Manifest check |
| 42 | const manifestLink = document.querySelector('link[rel="manifest"]'); |
| 43 | results.manifest = !!manifestLink; |
| 44 | |
| 45 | if (manifestLink) { |
| 46 | const resp = await fetch(manifestLink.href); |
| 47 | results.validManifest = resp.ok; |
| 48 | const manifest = await resp.json(); |
| 49 | results.hasIcons = manifest.icons?.length > 0; |
| 50 | results.hasName = !!manifest.name; |
| 51 | results.hasStartUrl = !!manifest.start_url; |
| 52 | } |
| 53 | |
| 54 | // Cache check |
| 55 | results.caches = await caches.keys().then((n) => n.length > 0); |
| 56 | |
| 57 | return results; |
| 58 | } |
best practice
- A valid manifest.json with icons, name, and start_url is required for installability
- Service Workers must be registered over HTTPS and follow install/activate/fetch lifecycle
- Choose caching strategies based on content type: Cache First for static, Network First for APIs
- Handle offline gracefully with fallback pages and background sync for queued actions
- Use Workbox for production service workers to avoid manual cache management
- Always prompt users for notification permission with clear value proposition
- Test with Chrome DevTools and Lighthouse PWA audits before deploying