Progressive Web Apps (PWAs) combine the best of web and native applications. They're installable, work offline, and provide a native-like experience. This guide covers everything you need to build production-ready PWAs.
What Makes a PWA?
- Installable: Users can install on their devices
- Offline Capable: Works without internet connection
- Fast: Loads quickly, even on slow networks
- Responsive: Works on any device and screen size
- Secure: Served over HTTPS
- Engaging: Push notifications and background sync
Service Workers: The Heart of PWAs
Service Workers are JavaScript files that act as a proxy between your app and the network. They enable offline functionality, background sync, and push notifications.
Basic Service Worker Setup
// sw.js - Service Worker
const CACHE_NAME = 'my-pwa-v1';
const urlsToCache = [
'/',
'/styles/main.css',
'/script/main.js',
'/images/logo.png',
];
// Install event - cache resources
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => cache.addAll(urlsToCache))
);
});
// 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
return response || fetch(event.request);
})
);
});
// Activate event - clean up old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheName !== CACHE_NAME) {
return caches.delete(cacheName);
}
})
);
})
);
});
Registering Service Worker
// In your main JavaScript file
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);
});
});
}
Advanced Caching Strategies
Cache First (Static Assets)
self.addEventListener('fetch', (event) => {
if (event.request.destination === 'image' ||
event.request.destination === 'style') {
event.respondWith(
caches.match(event.request).then((response) => {
return response || fetch(event.request).then((fetchResponse) => {
return caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, fetchResponse.clone());
return fetchResponse;
});
});
})
);
}
});
Network First (Dynamic Content)
self.addEventListener('fetch', (event) => {
if (event.request.url.includes('/api/')) {
event.respondWith(
fetch(event.request)
.then((response) => {
const responseClone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseClone);
});
return response;
})
.catch(() => {
return caches.match(event.request);
})
);
}
});
Stale-While-Revalidate
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.open(CACHE_NAME).then((cache) => {
return cache.match(event.request).then((cachedResponse) => {
const fetchPromise = fetch(event.request).then((networkResponse) => {
cache.put(event.request, networkResponse.clone());
return networkResponse;
});
return cachedResponse || fetchPromise;
});
})
);
});
Web App Manifest
The manifest file tells the browser how your app should behave when installed.
// manifest.json
{
"name": "CodeMatic PWA",
"short_name": "CodeMatic",
"description": "Progressive Web App example",
"start_url": "/",
"display": "standalone",
"background_color": "#0F172A",
"theme_color": "#28C55E",
"orientation": "portrait-primary",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"screenshots": [
{
"src": "/screenshots/desktop.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide"
}
],
"categories": ["productivity", "business"],
"shortcuts": [
{
"name": "Dashboard",
"short_name": "Dashboard",
"description": "Open dashboard",
"url": "/dashboard",
"icons": [{ "src": "/icons/dashboard.png", "sizes": "96x96" }]
}
]
}
Linking Manifest in HTML
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#28C55E">
<link rel="apple-touch-icon" href="/icons/icon-192.png">
Background Sync
Background Sync allows your app to defer actions until the user has connectivity.
// Register background sync
async function syncData() {
const registration = await navigator.serviceWorker.ready;
try {
await registration.sync.register('sync-data');
console.log('Background sync registered');
} catch (error) {
console.log('Background sync failed:', error);
}
}
// In Service Worker
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-data') {
event.waitUntil(
fetch('/api/sync', {
method: 'POST',
body: JSON.stringify(getPendingData()),
})
);
}
});
Push Notifications
Engage users with push notifications even when your app is closed.
Requesting Permission and Subscribing
async function subscribeToPush() {
const registration = await navigator.serviceWorker.ready;
try {
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(
'YOUR_VAPID_PUBLIC_KEY'
),
});
// Send subscription to server
await fetch('/api/push-subscribe', {
method: 'POST',
body: JSON.stringify(subscription),
});
} catch (error) {
console.error('Push subscription failed:', error);
}
}
// Handle push notifications in Service Worker
self.addEventListener('push', (event) => {
const data = event.data.json();
const options = {
body: data.body,
icon: '/icons/icon-192.png',
badge: '/icons/badge.png',
vibrate: [200, 100, 200],
tag: data.tag,
actions: [
{ action: 'view', title: 'View' },
{ action: 'close', title: 'Close' },
],
};
event.waitUntil(
self.registration.showNotification(data.title, options)
);
});
// Handle notification clicks
self.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.action === 'view') {
event.waitUntil(
clients.openWindow('/')
);
}
});
PWA in Next.js
Next.js has excellent PWA support. Use next-pwa for easy integration.
// next.config.js
const withPWA = require('next-pwa')({
dest: 'public',
register: true,
skipWaiting: true,
disable: process.env.NODE_ENV === 'development',
});
module.exports = withPWA({
// Your Next.js config
});
// public/manifest.json (as shown above)
// Install prompt component
function InstallPWA() {
const [deferredPrompt, setDeferredPrompt] = useState(null);
useEffect(() => {
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
setDeferredPrompt(e);
});
}, []);
const handleInstall = async () => {
if (deferredPrompt) {
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === 'accepted') {
setDeferredPrompt(null);
}
}
};
return deferredPrompt && (
<button onClick={handleInstall}>
Install App
</button>
);
}
Performance Metrics for PWAs
- Lighthouse Score: Aim for 90+ in all categories
- First Contentful Paint: < 1.8s
- Time to Interactive: < 3.8s
- Offline Functionality: Core features work offline
- Installable: Meets installability criteria
Real-World Example
We built a PWA for a restaurant ordering system:
- Offline menu browsing with image caching
- Order queueing when offline, sync when online
- Push notifications for order status updates
- Installable on all devices
- Result: 40% increase in repeat orders, 60% faster load times
Conclusion
PWAs offer a powerful way to create native-like experiences on the web. Start with service workers for offline functionality, add a manifest for installability, then enhance with push notifications and background sync. Focus on performance and user experience to create compelling PWAs.