Building Progressive Web Apps (PWA)
Progressive Web Apps (PWAs) represent a paradigm shift in web development, combining the best of web and native applications. They're websites that can be installed on devices, work offline, send push notifications, and provide an app-like experienceβall while being built with standard web technologies like HTML, CSS, and JavaScript.
In this comprehensive guide, we'll explore everything you need to know to build production-ready PWAs, from understanding service workers to implementing advanced caching strategies and making your app fully installable across all platforms.
- Understanding the core principles and architecture of PWAs
- Implementing Service Workers for offline functionality
- Creating a Web App Manifest for installability
- Mastering caching strategies (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 PWAs with Lighthouse
What Makes an App "Progressive"?
The term "Progressive" refers to the philosophy of progressively enhancing your web app based on the capabilities of the user's browser and device. A PWA works for everyone but provides enhanced experiences for users with modern browsers.
The three core pillars that define a Progressive Web App
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
Service Workers are JavaScript files that run in the background, separate from your web page. They act as a proxy between your web app and the network, enabling features like offline functionality, push notifications, and background sync.
Service Workers intercept all network requests and can serve cached content or fetch from network
Service Worker Lifecycle
Understanding the service worker lifecycle is crucial for building reliable PWAs. A service worker goes through several states from registration to activation.
1. Install Event
Triggered when the service worker is first registered. Use this to cache static assets (app shell).
2. Activate Event
Triggered after installation when the SW takes control. Clean up old caches here.
3. Fetch Event
Triggered on every network request. Implement your caching strategy 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');
}
})
);
});
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
The Web App Manifest is a JSON file that tells the browser about your PWA and how it should behave when installed. It enables the "Add to Home Screen" functionality and controls the splash screen, app name, icons, and more.
{
"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
Choosing the right caching strategy is crucial for balancing between performance and data freshness. Here are the most common strategies used in PWAs:
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
One of the most engaging features of PWAs is the ability to prompt users to install the app. Here's how to implement a custom install experience:
π± Install Our App
Get the full experience with our progressive web app!
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 notifications allow you to re-engage users even when they're not actively using your app. They're one of the most powerful features of PWAs for driving user engagement.
// 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 allows you to defer actions until the user has stable connectivity. This is perfect for ensuring that user actions like form submissions or data uploads are never lost, even if the connection drops.
// 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
Workbox is a set of libraries from Google that makes building PWAs much easier. It handles all the complexity of service worker caching strategies, precaching, and more with minimal configuration.
// 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 your PWA to production, make sure you've covered all the essential requirements:
Testing with Lighthouse
Lighthouse is an open-source tool from Google that audits your PWA for performance, accessibility, and best practices. You can run it directly from Chrome DevTools.
Open Chrome DevTools
Right-click on your page and select "Inspect" or press F12
Navigate to Lighthouse Tab
Click on the "Lighthouse" tab in DevTools
Select PWA Category
Check "Progressive Web App" along with other categories
Run the Audit
Click "Analyze page load" and wait for the report
- Test on real devices: Emulators don't catch all PWA issues
- 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
Real-World PWA Success Stories
Major companies have seen significant improvements after implementing PWAs:
Twitter Lite
65% increase in pages per session, 75% increase in Tweets sent
Alibaba
76% higher conversions across browsers
60% increase in core engagements, 44% user-generated ad revenue increase
Forbes
100% increase in engagement, 43% more sessions per user
Conclusion
Progressive Web Apps represent the future of web development, bridging the gap between web and native applications. By implementing service workers, web app manifests, and modern caching strategies, you can create fast, reliable, and engaging experiences that work for all users.
The key takeaways from this guide:
- Service Workers are the backbone of PWAs, enabling offline functionality and advanced caching
- Web App Manifest makes your app installable and controls its appearance
- Caching strategies should be chosen based on your content type and freshness requirements
- Push notifications and background sync enable re-engagement and reliable data sync
- Workbox simplifies PWA development with pre-built strategies and tools
- Lighthouse helps you audit and optimize your PWA
Start building your PWA today and deliver app-like experiences to your users without the friction of app store downloads!
