Progressive Web Apps (PWAs) bring native app-like capabilities to the web: offline support, push notifications, home screen installation, and fast loading on poor connections. The building blocks are a service worker, a web app manifest, and HTTPS.
What Makes a PWA
A PWA isn't a framework or tool — it's a set of features your web app gains:
- Installable: users can add it to their home screen or taskbar
- Offline capable: works without a network connection (or with a degraded fallback)
- Fast: resources are cached; repeat visits load instantly
- Reliable: doesn't break on flaky connections
You don't need all three features at once. Incrementally add the ones your users benefit from.
The Web App Manifest
The manifest tells the browser how to present your app when installed:
// manifest.json { "name": "My App", "short_name": "MyApp", "description": "App that does useful things", "start_url": "/", "display": "standalone", "background_color": "#ffffff", "theme_color": "#2563eb", "icons": [ { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" }, { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }, { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } ] } Link it in your HTML:
<link rel="manifest" href="/manifest.json"> <meta name="theme-color" content="#2563eb"> display: "standalone" removes the browser UI (address bar, nav buttons) when launched from the home screen. Other options: minimal-ui, fullscreen, browser.
The maskable icon is important — Android applies a shape mask to home screen icons. Maskable icons are designed to fill the entire icon space without clipping.
Service Workers
A service worker is a JavaScript file that runs in the background, intercepting network requests and managing caches. It's the key to offline support.
// sw.js — register in your main JS: if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/sw.js'); } Cache-First Strategy (for static assets)
// sw.js const CACHE_NAME = 'my-app-v1'; const STATIC_ASSETS = [ '/', '/styles/main.css', '/scripts/app.js', '/icons/icon-192.png' ]; // Install: cache static assets self.addEventListener('install', event => { event.waitUntil( caches.open(CACHE_NAME).then(cache => cache.addAll(STATIC_ASSETS)) ); self.skipWaiting(); }); // Activate: clean up old caches self.addEventListener('activate', event => { event.waitUntil( caches.keys().then(keys => Promise.all( keys .filter(key => key !== CACHE_NAME) .map(key => caches.delete(key)) ) ) ); self.clients.claim(); }); // Fetch: serve from cache, fall back to network self.addEventListener('fetch', event => { event.respondWith( caches.match(event.request).then(cached => { return cached || fetch(event.request); }) ); }); Network-First Strategy (for API data)
For dynamic content, try the network first and fall back to cache:
self.addEventListener('fetch', event => { const url = new URL(event.request.url); // Static assets: cache-first if (url.pathname.startsWith('/static/')) { event.respondWith(cacheFirst(event.request)); return; } // API calls: network-first if (url.pathname.startsWith('/api/')) { event.respondWith(networkFirst(event.request)); return; } }); async function cacheFirst(request) { const cached = await caches.match(request); return cached || fetch(request); } async function networkFirst(request) { const cache = await caches.open('api-cache'); try { const response = await fetch(request); cache.put(request, response.clone()); return response; } catch { return cache.match(request); } } Stale-While-Revalidate (best of both)
Serve cached content immediately, update cache in the background:
async function staleWhileRevalidate(request) { const cache = await caches.open('swre-cache'); const cached = await cache.match(request); const fetchPromise = fetch(request).then(response => { cache.put(request, response.clone()); return response; }); return cached || fetchPromise; } Offline Fallback Page
When a navigation request fails (user visits a page they haven't cached), show a fallback:
// Cache the offline page during install self.addEventListener('install', event => { event.waitUntil( caches.open(CACHE_NAME).then(cache => cache.addAll(['/offline.html', ...STATIC_ASSETS]) ) ); }); // Serve offline page for failed navigations self.addEventListener('fetch', event => { if (event.request.mode === 'navigate') { event.respondWith( fetch(event.request).catch(() => caches.match('/offline.html')) ); } }); Background Sync
Queue failed requests and replay them when connectivity returns:
// Register sync when a request fails async function saveData(data) { try { await fetch('/api/save', { method: 'POST', body: JSON.stringify(data) }); } catch { // Store in IndexedDB for later sync await queueForSync(data); await navigator.serviceWorker.ready; await registration.sync.register('save-data'); } } // In service worker self.addEventListener('sync', event => { if (event.tag === 'save-data') { event.waitUntil(flushQueuedData()); } }); Push Notifications
Push notifications require a subscription and a server to send them:
// Request permission and get subscription async function subscribeToPush() { const registration = await navigator.serviceWorker.ready; const subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, // required: all pushes must show a notification applicationServerKey: urlBase64ToUint8Array(PUBLIC_VAPID_KEY) }); // Send subscription to your server await fetch('/api/push-subscribe', { method: 'POST', body: JSON.stringify(subscription) }); } // In service worker: handle incoming push self.addEventListener('push', event => { const data = event.data?.json() ?? { title: 'Update available' }; event.waitUntil( self.registration.showNotification(data.title, { body: data.body, icon: '/icons/icon-192.png' }) ); }); Installation Prompt
You can intercept the browser's install prompt to show it at the right moment:
let deferredPrompt; window.addEventListener('beforeinstallprompt', event => { event.preventDefault(); deferredPrompt = event; document.getElementById('install-btn').hidden = false; }); document.getElementById('install-btn').addEventListener('click', async () => { deferredPrompt.prompt(); const { outcome } = await deferredPrompt.userChoice; console.log(`User ${outcome === 'accepted' ? 'installed' : 'dismissed'} the app`); deferredPrompt = null; }); Testing PWAs
In Firefox DevTools:
- Open Application tab (Firefox DevTools → Application panel)
- Check Service Workers: registration, status, cache storage
- Simulate offline: Network tab → Throttle → Offline
Lighthouse PWA audit:
npx lighthouse https://yoursite.com --categories=pwa Firefox PWA Support
Firefox supports service workers, web app manifests, and offline caching. As of 2023, Firefox desktop removed its PWA installation feature (the "install as app" button) on desktop platforms, though it remains in Firefox for Android.
For a purely web-based PWA without installation (just offline and caching benefits), Firefox works fully. For installable PWAs, Chrome/Edge on desktop and Firefox Android are your targets.
The Workbox library from Google simplifies service worker development significantly — worth evaluating if your caching strategy gets complex.


