Building Progressive Web Apps (PWA)
A user opens your web app on a train, the signal drops, and they get the browser's offline dinosaur instead of the order they were halfway through. Or a client asks for "an app" and you know that means two native codebases, two store reviews, and a release cycle you don't control. A Progressive Web App (PWA) is the middle ground: a normal website, built with HTML, CSS and JavaScript, that can be installed, keeps working offline, and can send push notifications.
This post goes through the pieces you actually need to ship one: a service worker, a manifest, a caching strategy per content type, the install prompt, push, background sync, Workbox, and how to test it all.
- What "progressive" means and how a PWA is put together
- Implementing Service Workers for offline functionality
- Creating a Web App Manifest for installability
- Picking a caching strategy (Cache First, Network First, Stale-While-Revalidate)
- Implementing push notifications
- Background sync and periodic background sync
- Using Workbox to simplify PWA development
- Testing and auditing with DevTools and Lighthouse
What Makes an App "Progressive"?
"Progressive" comes from progressive enhancement. The site has to work in any browser, and then you add offline support, installability and push on top wherever the browser supports them. If a feature isn't there, the user just gets a regular website, not a broken one.
The three qualities people usually mean when they say "PWA"
PWA vs Native Apps vs Traditional Web
| Feature | Traditional Web | PWA | Native App |
|---|---|---|---|
| Installation | Not needed | Optional, instant | Required via app store |
| Offline Support | ❌ No | ✅ Yes | ✅ Yes |
| Push Notifications | ❌ No | ✅ Yes | ✅ Yes |
| App Store | Not applicable | Optional | Required |
| Updates | Automatic | Automatic | Manual approval |
| Development Cost | Low | Low-Medium | High (per platform) |
| Discoverability | ✅ SEO/Links | ✅ SEO/Links | App Store only |
| Hardware Access | Limited | Good (improving) | Full |
Service Workers: The Heart of PWAs
Almost everything that makes a PWA more than a website goes through one file. A service worker runs in the background on its own thread, with no DOM access, and sits between your pages and the network like a small programmable proxy. Offline support, push and background sync all hang off it.
Service Workers intercept all network requests and can serve cached content or fetch from network
Service Worker Lifecycle
Most "why is my site still showing the old version?" bugs come from the lifecycle. A new service worker installs alongside the old one and, by default, waits until every tab using the old one is closed before it takes over.
1. Install Event
Fires once per new version of the file. This is where you precache static assets (the app shell).
2. Activate Event
Fires when the new SW takes control. Delete old caches here.
3. Fetch Event
Fires for every request the page makes in scope. Your caching strategy lives here.
Basic Service Worker Implementation
const CACHE_NAME = 'my-pwa-cache-v1';
const urlsToCache = [
'/',
'/index.html',
'/styles.css',
'/app.js',
'/images/logo.png',
'/offline.html'
];
// Install event - cache static assets
self.addEventListener('install', event => {
console.log('Service Worker: Installing...');
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => {
console.log('Service Worker: Caching app shell');
return cache.addAll(urlsToCache);
})
.then(() => self.skipWaiting())
);
});
// Activate event - clean up old caches
self.addEventListener('activate', event => {
console.log('Service Worker: Activating...');
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cache => {
if (cache !== CACHE_NAME) {
console.log('Service Worker: Clearing old cache');
return caches.delete(cache);
}
})
);
}).then(() => self.clients.claim())
);
});
// Fetch event - serve from cache, fallback to network
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(response => {
// Return cached version or fetch from network
if (response) {
return response;
}
return fetch(event.request);
})
.catch(() => {
// If both cache and network fail, show offline page
if (event.request.mode === 'navigate') {
return caches.match('/offline.html');
}
})
);
});
cache.addAll()is all or nothing. If a single URL inurlsToCachereturns a 404 (a renamed image, a hashed filename that changed on deploy), the whole install fails and the new worker never activates. In the console you'll see something likeUncaught (in promise) TypeError: Failed to execute 'addAll' on 'Cache': Request failed.- Scope follows the file's location. A worker served from
/js/sw.jscan only control pages under/js/. Serve it from the site root, and send it withCache-Control: no-cacheso a CDN or browser cache doesn't keep handing out the old version.
Registering the Service Worker
// Register service worker
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const registration = await navigator.serviceWorker.register('/sw.js');
console.log('ServiceWorker registered:', registration.scope);
// Check for updates
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
console.log('New service worker available!');
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed') {
// New content available, notify user
showUpdateNotification();
}
});
});
} catch (error) {
console.error('ServiceWorker registration failed:', error);
}
});
}
Web App Manifest: Making Your App Installable
Without a manifest the browser has no name, icon or start URL to use, so it won't offer to install your site. The manifest is a small JSON file that supplies those, plus the splash screen colours and how the app window should look once it's installed.
{
"name": "My Progressive Web App",
"short_name": "MyPWA",
"description": "A fast, reliable, and engaging PWA",
"start_url": "/?source=pwa",
"scope": "/",
"display": "standalone",
"orientation": "portrait-primary",
"background_color": "#ffffff",
"theme_color": "#5a0fc8",
"icons": [
{
"src": "/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable any"
}
],
"screenshots": [
{
"src": "/screenshots/desktop.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide"
},
{
"src": "/screenshots/mobile.png",
"sizes": "750x1334",
"type": "image/png",
"form_factor": "narrow"
}
],
"shortcuts": [
{
"name": "New Task",
"short_name": "Task",
"url": "/tasks/new",
"icons": [{ "src": "/icons/add-task.png", "sizes": "192x192" }]
}
],
"categories": ["productivity", "utilities"]
}
- Maskable icons: Use the
purpose: "maskable"for icons that will be cropped on Android - Display modes: Options include
fullscreen,standalone,minimal-ui, andbrowser - start_url: Add a query parameter to track PWA launches in analytics
- Screenshots: Required for the enhanced install dialog on desktop Chrome
Linking the Manifest
<head>
<!-- Link to manifest -->
<link rel="manifest" href="/manifest.json">
<!-- Theme color for browser UI -->
<meta name="theme-color" content="#5a0fc8">
<!-- iOS-specific meta tags -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="MyPWA">
<!-- iOS icons -->
<link rel="apple-touch-icon" href="/icons/icon-192x192.png">
<!-- iOS splash screens -->
<link rel="apple-touch-startup-image" href="/splash/splash-640x1136.png">
</head>
Caching Strategies
There's no single right strategy. You trade speed against freshness, and the answer is different for your CSS bundle than for your /api/cart endpoint. Most apps end up with a mix of these three:
Choose your caching strategy based on content type and freshness requirements
Implementing Caching Strategies
// Cache First - Good for static assets
self.addEventListener('fetch', event => {
if (event.request.destination === 'image' ||
event.request.destination === 'style' ||
event.request.destination === 'script') {
event.respondWith(
caches.match(event.request)
.then(cachedResponse => {
if (cachedResponse) {
return cachedResponse;
}
return fetch(event.request).then(response => {
// Cache the new response
const responseClone = response.clone();
caches.open('static-cache').then(cache => {
cache.put(event.request, responseClone);
});
return response;
});
})
);
}
});
// Network First - Good for API calls and HTML
self.addEventListener('fetch', event => {
if (event.request.url.includes('/api/')) {
event.respondWith(
fetch(event.request)
.then(response => {
// Clone and cache the response
const responseClone = response.clone();
caches.open('api-cache').then(cache => {
cache.put(event.request, responseClone);
});
return response;
})
.catch(() => {
// Network failed, try cache
return caches.match(event.request);
})
);
}
});
// Stale-While-Revalidate - Best of both worlds
self.addEventListener('fetch', event => {
event.respondWith(
caches.open('dynamic-cache').then(async cache => {
const cachedResponse = await cache.match(event.request);
// Fetch fresh data in the background
const fetchPromise = fetch(event.request).then(networkResponse => {
cache.put(event.request, networkResponse.clone());
return networkResponse;
});
// Return cached version immediately, or wait for network
return cachedResponse || fetchPromise;
})
);
});
Handling the Install Prompt
Chromium browsers fire a beforeinstallprompt event when your site meets the install criteria. You can grab it and show the prompt from your own button, at a moment that makes sense, instead of letting the browser pick. Safari and Firefox don't fire this event, so on iOS users still install through Share, then "Add to Home Screen".
📱 Install Our App
Add it to your home screen and it opens in its own window.
let deferredPrompt;
const installButton = document.getElementById('installButton');
const installBanner = document.getElementById('installBanner');
// Capture the install prompt event
window.addEventListener('beforeinstallprompt', (e) => {
// Prevent the default mini-infobar
e.preventDefault();
// Store the event for later use
deferredPrompt = e;
// Show your custom install UI
installBanner.style.display = 'block';
// Track that install prompt was shown
trackEvent('pwa_install_prompt_shown');
});
// Handle the install button click
installButton.addEventListener('click', async () => {
if (!deferredPrompt) {
return;
}
// Show the native install prompt
deferredPrompt.prompt();
// Wait for the user's response
const { outcome } = await deferredPrompt.userChoice;
if (outcome === 'accepted') {
console.log('User accepted the install prompt');
trackEvent('pwa_install_accepted');
} else {
console.log('User dismissed the install prompt');
trackEvent('pwa_install_dismissed');
}
// Clear the deferredPrompt
deferredPrompt = null;
installBanner.style.display = 'none';
});
// Detect when the PWA was successfully installed
window.addEventListener('appinstalled', (e) => {
console.log('PWA was installed!');
trackEvent('pwa_installed');
// Hide the install UI
installBanner.style.display = 'none';
});
// Check if app is running in standalone mode (installed)
if (window.matchMedia('(display-mode: standalone)').matches) {
console.log('App is running in standalone mode');
// Adjust UI for installed app experience
}
Push Notifications
Push lets your server reach a user when the app isn't open, because the browser wakes your service worker to show the notification. It's also the fastest way to get your site blocked. Ask for permission after the user does something that makes notifications useful (subscribing to an order update, say), never on page load. On iOS, web push only works once the PWA has been added to the home screen (iOS 16.4 and later).
// Request permission and subscribe to push notifications
async function subscribeToPush() {
try {
// Request notification permission
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
console.log('Notification permission denied');
return;
}
// Get the service worker registration
const registration = await navigator.serviceWorker.ready;
// Subscribe to push notifications
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
});
// Send subscription to your server
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription)
});
console.log('Successfully subscribed to push notifications!');
} catch (error) {
console.error('Error subscribing to push:', error);
}
}
// Helper function to convert VAPID key
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding).replace(/\-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
return Uint8Array.from([...rawData].map(char => char.charCodeAt(0)));
}
Handling Push Events in Service Worker
// Handle incoming push notifications
self.addEventListener('push', event => {
let data = { title: 'New Notification', body: 'You have a new message!' };
if (event.data) {
data = event.data.json();
}
const options = {
body: data.body,
icon: '/icons/icon-192x192.png',
badge: '/icons/badge-72x72.png',
vibrate: [100, 50, 100],
data: {
url: data.url || '/',
dateOfArrival: Date.now()
},
actions: [
{ action: 'open', title: 'Open', icon: '/icons/open.png' },
{ action: 'dismiss', title: 'Dismiss', icon: '/icons/dismiss.png' }
]
};
event.waitUntil(
self.registration.showNotification(data.title, options)
);
});
// Handle notification click
self.addEventListener('notificationclick', event => {
event.notification.close();
if (event.action === 'dismiss') {
return;
}
const urlToOpen = event.notification.data.url;
event.waitUntil(
clients.matchAll({ type: 'window' }).then(windowClients => {
// Check if a window is already open
for (const client of windowClients) {
if (client.url === urlToOpen && 'focus' in client) {
return client.focus();
}
}
// Open a new window
return clients.openWindow(urlToOpen);
})
);
});
Background Sync
Background Sync lets you hold a request until the device is back online, so a form submitted in a tunnel still reaches your server later. Browser support is the catch: it's Chromium only. Safari and Firefox don't have the sync API, so keep a fallback that retries from the page on the next load.
// Register a background sync when offline
async function submitFormWithSync(formData) {
try {
// Try to submit immediately
await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify(formData)
});
showNotification('Form submitted successfully!');
} catch (error) {
// Network failed, queue for background sync
await storeFormData(formData);
await registerBackgroundSync('form-sync');
showNotification('Form saved! Will sync when online.');
}
}
async function registerBackgroundSync(tag) {
const registration = await navigator.serviceWorker.ready;
await registration.sync.register(tag);
}
// Store data in IndexedDB for later sync
async function storeFormData(data) {
const db = await openDB('sync-queue', 1);
await db.add('pending-forms', {
data,
timestamp: Date.now()
});
}
// Handle background sync in service worker
self.addEventListener('sync', event => {
if (event.tag === 'form-sync') {
event.waitUntil(syncPendingForms());
}
});
async function syncPendingForms() {
const db = await openDB('sync-queue', 1);
const pendingForms = await db.getAll('pending-forms');
for (const form of pendingForms) {
try {
await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify(form.data)
});
await db.delete('pending-forms', form.id);
} catch (error) {
// Sync will be retried automatically
throw error;
}
}
}
Using Workbox for Easier PWA Development
Writing fetch handlers by hand is fine for learning, but in a real project I'd use Workbox. It's Google's set of service worker libraries, and it covers the parts that are easy to get wrong: precaching with revisioned URLs, cache expiration, and the strategies above as ready-made classes.
// workbox-config.js
module.exports = {
globDirectory: 'dist/',
globPatterns: [
'**/*.{html,js,css,png,jpg,svg,woff2}'
],
swDest: 'dist/sw.js',
runtimeCaching: [
{
urlPattern: /^https:\/\/api\.example\.com\//,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: {
maxEntries: 50,
maxAgeSeconds: 86400
}
}
},
{
urlPattern: /\.(?:png|jpg|jpeg|svg|gif)$/,
handler: 'CacheFirst',
options: {
cacheName: 'image-cache',
expiration: {
maxEntries: 100,
maxAgeSeconds: 604800
}
}
}
]
};
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import {
CacheFirst,
NetworkFirst,
StaleWhileRevalidate
} from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
// Precache static assets
precacheAndRoute(self.__WB_MANIFEST);
// Cache images with Cache First strategy
registerRoute(
({ request }) => request.destination === 'image',
new CacheFirst({
cacheName: 'images',
plugins: [
new ExpirationPlugin({
maxEntries: 60,
maxAgeSeconds: 30 * 24 * 60 * 60 // 30 days
})
]
})
);
// Cache API calls with Network First strategy
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new NetworkFirst({
cacheName: 'api-responses',
networkTimeoutSeconds: 3,
plugins: [
new ExpirationPlugin({
maxEntries: 50,
maxAgeSeconds: 24 * 60 * 60 // 24 hours
})
]
})
);
// Cache pages with Stale While Revalidate
registerRoute(
({ request }) => request.mode === 'navigate',
new StaleWhileRevalidate({
cacheName: 'pages'
})
);
PWA Checklist
Before deploying, go through this list:
Testing with Lighthouse
Older tutorials tell you to tick the "Progressive Web App" box in Lighthouse. That category was removed in Lighthouse 12 (2024), so newer Chrome versions don't have it. These days I check installability in the Application panel and use Lighthouse for performance and best practices.
Open Chrome DevTools
Right-click on your page and select "Inspect" or press F12
Check the Application Panel
Under "Manifest", Chrome lists any installability errors (missing icon size, bad start_url). Under "Service workers", confirm yours is activated and running.
Test Offline
Tick "Offline" in the Service workers section (or the Network tab throttling menu) and reload. You should get your app or offline page, not the dinosaur.
Run Lighthouse
Open the "Lighthouse" tab, pick Performance and Best Practices, and click "Analyze page load"
- Test on real devices: Emulators miss a lot, especially iOS install and push behaviour
- Tick "Update on reload" while developing: it's in Application, then Service workers, and saves you from closing every tab to see your changes
- Use Chrome's Application panel: Debug service workers, manifest, and cache
- Version your cache: Always change cache names when updating static assets
- Handle updates gracefully: Notify users when new versions are available
- Monitor with analytics: Track install rates, offline usage, and engagement
Who Has Shipped PWAs
Several large sites have published case studies on their PWAs, mostly built for users on slow mobile connections. Treat the numbers in those write-ups as their results, not something you'll automatically get:
Twitter Lite
Reported more pages per session and more Tweets sent after the switch
Alibaba
Reported higher mobile conversions across browsers
Rebuilt its mobile site as a PWA and reported higher engagement and ad revenue
Forbes
Reported longer sessions and more engagement on mobile
When I'd Build a PWA (and When I Wouldn't)
A PWA is a good fit when most of your users are on mobile web already, connections are unreliable, and the app is mostly content, forms and dashboards. Order tracking, internal tools for field staff, booking flows and news sites all fit. You get one codebase, instant updates and no store review, and you can still start with just a manifest and a small service worker and add more later.
I wouldn't pick it when:
- Your users are mostly on iPhones and you need push or background work. iOS supports web push only for installed PWAs, has no Background Sync, and can clear storage for sites that haven't been used for a while.
- You need deep hardware access. Bluetooth, NFC, background location and similar APIs are Chromium only, missing, or restricted on the web. React Native or native code is the honest answer here.
- Being in the App Store is a business requirement. You can wrap a PWA (Trusted Web Activity on Android, a wrapper on iOS), but then you're back to store reviews anyway.
- Data has to be fresh every time. Banking balances or stock levels shouldn't sit behind a cache-first rule. Use network-first for those, or skip caching them entirely.
The mistake I'd watch for most is caching too much too early. A cache-first rule on your HTML, or an app shell that never gets a new cache name, will happily serve last month's build to users long after you've deployed. Start with network-first for pages, cache-first only for hashed static files, and test what an update actually looks like before you ship.