Progressive Web Apps blur the line between web and native. Here's how to build PWAs that users love.
What Makes a PWA?
A Progressive Web App requires:
- HTTPS: Secure connection required
- Service Worker: Enables offline functionality
- Web App Manifest: Controls installation and appearance
- Responsive Design: Works on all devices
- Safe Browsing: No malicious content
Service Worker Fundamentals
Registration
Register your service worker.
// public/sw.js
const CACHE_NAME = 'v1';
const ASSETS = [
'/',
'/styles.css',
'/app.js',
'/offline.html',
];
// Install event - cache assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(ASSETS);
})
);
});
// Activate event - clean old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) => {
return Promise.all(
keys
.filter((key) => key !== CACHE_NAME)
.map((key) => caches.delete(key))
);
})
);
});
// Fetch event - serve from cache, fallback to network
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((response) => {
return response || fetch(event.request);
})
);
});Register in App
// src/index.js
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker
.register('/sw.js')
.then((registration) => {
console.log('SW registered:', registration);
})
.catch((error) => {
console.log('SW registration failed:', error);
});
});
}Caching Strategies
Cache First
Best for static assets.
self.addEventListener('fetch', (event) => {
// Cache first, fallback to network
if (event.request.url.includes('/static/')) {
event.respondWith(
caches.match(event.request).then((response) => {
return response || fetch(event.request);
})
);
}
});Network First
Best for API calls where fresh data matters.
self.addEventListener('fetch', (event) => {
if (event.request.url.includes('/api/')) {
event.respondWith(
fetch(event.request)
.then((response) => {
// Cache successful responses
if (response.status === 200) {
const responseClone = response.clone();
caches.open('api-cache').then((cache) => {
cache.put(event.request, responseClone);
});
}
return response;
})
.catch(() => {
// Fallback to cache
return caches.match(event.request);
})
);
}
});Stale While Revalidate
Balance speed and freshness.
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.open('dynamic-cache').then((cache) => {
return cache.match(event.request).then((response) => {
// Fetch in background
const fetchPromise = fetch(event.request).then((networkResponse) => {
cache.put(event.request, networkResponse.clone());
return networkResponse;
});
// Return cached version immediately
return response || fetchPromise;
});
})
);
});Web App Manifest
Basic Manifest
{
"name": "My PWA",
"short_name": "PWA",
"description": "A progressive web app",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#3b82f6",
"orientation": "portrait-primary",
"icons": [
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-512x512.png",
"_sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}Link in HTML
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#3b82f6">
<link rel="apple-touch-icon" href="/icons/icon-192x192.png">Installation
Detect Installability
// Track install prompt
let deferredPrompt;
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
// Show install button
installButton.style.display = 'block';
});
installButton.addEventListener('click', async () => {
if (deferredPrompt) {
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === 'accepted') {
console.log('User accepted install');
}
deferredPrompt = null;
}
});
// Detect if app is already installed
window.addEventListener('appinstalled', () => {
installButton.style.display = 'none';
});Offline Experience
Offline Page
<!-- offline.html -->
<!DOCTYPE html>
<html>
<head>
<title>You're Offline</title>
<style>
body {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
font-family: system-ui;
text-align: center;
}
</style>
</head>
<body>
<div>
<h1>You're Offline</h1>
<p>Please check your internet connection.</p>
<button onclick="location.reload()">Try Again</button>
</div>
</body>
</html>Offline Detection
// Monitor online/offline status
window.addEventListener('online', () => {
showNotification('You're back online!');
});
window.addEventListener('offline', () => {
showNotification('You're offline. Some features may be unavailable.');
});
// Check current status
if (navigator.onLine) {
console.log('Online');
} else {
console.log('Offline');
}Push Notifications
Request Permission
async function requestNotificationPermission() {
const permission = await Notification.requestPermission();
if (permission === 'granted') {
console.log('Notification permission granted');
// Subscribe to push
subscribeToPush();
} else {
console.log('Notification permission denied');
}
}Subscribe to Push
async function subscribeToPush() {
try {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: 'YOUR_VAPID_PUBLIC_KEY',
});
// Send subscription to server
await fetch('/api/subscribe', {
method: 'POST',
body: JSON.stringify(subscription),
headers: {
'Content-Type': 'application/json',
},
});
} catch (error) {
console.error('Push subscription failed:', error);
}
}Handle Push Events
// In service worker
self.addEventListener('push', (event) => {
const options = {
body: event.data.text(),
icon: '/icons/icon-192x192.png',
badge: '/icons/badge-72x72.png',
vibrate: [200, 100, 200],
data: {
url: '/',
},
};
event.waitUntil(
self.registration.showNotification('New Message', options)
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
event.waitUntil(
clients.openWindow(event.notification.data.url)
);
});Background Sync
Register Sync
async function registerBackgroundSync() {
const registration = await navigator.serviceWorker.ready;
await registration.sync.register('sync-data');
// Handle sync in service worker
}
// In service worker
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-data') {
event.waitUntil(syncData());
}
});
async function syncData() {
// Get data from IndexedDB
const data = await getDataFromIndexedDB();
// Send to server
await fetch('/api/sync', {
method: 'POST',
body: JSON.stringify(data),
});
}Performance
App Shell Model
Cache shell immediately, load content dynamically.
// Cache app shell
const APP_SHELL = [
'/',
'/app.css',
'/app.js',
'/icons/',
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open('shell-v1').then((cache) => {
return cache.addAll(APP_SHELL);
})
);
});
// Serve shell from cache, content from network
self.addEventListener('fetch', (event) => {
if (APP_SHELL.some(url => event.request.url.includes(url))) {
event.respondWith(
caches.match(event.request).then((response) => {
return response || fetch(event.request);
})
);
} else {
event.respondWith(fetch(event.request));
}
});Testing
Lighthouse PWA Audit
# Run Lighthouse
lighthouse https://your-pwa.com --viewCheck:
- Progressive Web App score: 100
- Performance: 90+
- Accessibility: 100
- Best Practices: 90+
Manual Testing
Test on real devices:
- 1Load app with network enabled
- 2Enable airplane mode
- 3Navigate app - should work offline
- 4Disable airplane mode
- 5Verify app syncs data
Best Practices
DO
- Implement proper caching strategies
- Provide meaningful offline experience
- Request permissions at appropriate times
- Test on real devices
- Monitor performance
- Keep service worker simple
DON'T
- Cache everything indiscriminately
- Ignore offline scenarios
- Spam notifications
- Forget HTTPS
- Skip testing
- Overcomplicate service worker
Tools
Workbox
Google's library for building service workers.
// Generate service worker with Workbox
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute, NetworkFirst } from 'workbox-routing';
import { NetworkOnly } from 'workbox-strategies';
// Precache assets
precacheAndRoute(self.__WB_MANIFEST);
// API routes - network first
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new NetworkFirst()
);PWA Builder
Generate manifests and icons.
# Install PWA Builder
npm install -g pwabuilder
# Generate assets
pwabuilder manifestConclusion
PWAs provide native app-like experiences on the web. Implement service workers for offline functionality, create proper manifests for installation, and use appropriate caching strategies. Test thoroughly on real devices and monitor performance. The investment pays off in better user experience and engagement.