A Progressive Web App (PWA) leverages modern browser APIs to deliver an experience previously restricted to native iOS/Android applications. PWAs can be installed on the home screen, function completely offline, sync data in the background, and receive push notifications. The architectural core of every PWA is the Service Worker.
Module 1: The Service Worker Architecture
A Service Worker is a JavaScript file that runs in a completely separate background thread from your main web page. It acts as a network proxy: intercepting all HTTP requests made by the browser and allowing you to programmatically decide whether to serve data from the network, a local cache, or an offline fallback.
Module 2: Mastering the Lifecycle
The most common source of PWA bugs is a misunderstanding of the SW lifecycle. A service worker is heavily cached by the browser and updates asynchronously.
The Lifecycle Stages
- 1. Registration: The main thread calls
navigator.serviceWorker.register('/sw.js'). - 2. Install: Triggered only once per version. This is where you pre-cache critical static assets (
index.html,main.css). - 3. Waiting: If the user has a tab open using version 1 of the SW, version 2 will install but remain in a 'waiting' state until ALL tabs using version 1 are closed. (This prevents version conflict crashes).
- 4. Activate: Version 2 takes control. This is where you delete old v1 caches to free up disk space.
- 5. Fetch: The active SW intercepts network requests.
Module 3: Advanced Caching with Workbox
Writing raw fetch event listeners is tedious and prone to edge-case bugs. Google's Workbox library abstracts these into robust caching strategies.
import { registerRoute } from 'workbox-routing';
import { StaleWhileRevalidate, CacheFirst, NetworkFirst } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { precacheAndRoute } from 'workbox-precaching';
// 1. Precache static assets injected by Webpack/Vite
// self.__WB_MANIFEST contains an array of hashed filenames: [{ url: '/main.a1b2.js', revision: null }]
precacheAndRoute(self.__WB_MANIFEST);
// 2. Cache-First Strategy for Images
// We cache images heavily because they rarely change and use high bandwidth.
registerRoute(
({request}) => request.destination === 'image',
new CacheFirst({
cacheName: 'image-cache',
plugins: [
new ExpirationPlugin({
maxEntries: 100, // Only keep the 100 most recent images
maxAgeSeconds: 30 * 24 * 60 * 60, // Expire after 30 days
}),
],
})
);
// 3. Stale-While-Revalidate for API Data
// Returns instantly from cache for a fast UI, but fetches fresh data in the background to update the cache for next time.
registerRoute(
({url}) => url.pathname.startsWith('/api/dashboard'),
new StaleWhileRevalidate({
cacheName: 'dashboard-data',
})
);
// 4. Network-First Strategy for User Profiles
// We always want fresh data, but if the user drives into a tunnel, we fallback to the cached version.
registerRoute(
({url}) => url.pathname.startsWith('/api/user'),
new NetworkFirst({
cacheName: 'user-profile',
networkTimeoutSeconds: 3, // Fallback to cache if network doesn't respond in 3 seconds
})
);Module 4: Background Sync for Offline Mutations
Caching allows users to READ data offline. Background Sync allows users to WRITE data offline. If a user submits a form while on an airplane, the request is intercepted, serialized into IndexedDB, and automatically transmitted by the browser hours later when Wi-Fi is restored.
import { BackgroundSyncPlugin } from 'workbox-background-sync';
import { NetworkOnly } from 'workbox-strategies';
const bgSyncPlugin = new BackgroundSyncPlugin('analyticsQueue', {
maxRetentionTime: 24 * 60 // Retry up to 24 hours (in minutes)
});
// Intercept POST requests to the analytics and form submission endpoints
registerRoute(
({url}) => url.pathname === '/api/submit-form',
new NetworkOnly({
plugins: [bgSyncPlugin]
}),
'POST'
);Module 5: Push Notifications
Push notifications bypass the app entirely. The server sends a payload to Google/Apple's Push Service, which wakes up the browser's Service Worker to display the alert.
async function subscribeToPush() {
// 1. Request OS level permission
const permission = await Notification.requestPermission();
if (permission !== 'granted') return;
// 2. Subscribe using your server's public VAPID key
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlB64ToUint8Array('YOUR_PUBLIC_VAPID_KEY')
});
// 3. Send the unique subscription object to your backend database
await fetch('/api/save-subscription', {
method: 'POST',
body: JSON.stringify(subscription)
});
}self.addEventListener('push', function(event) {
// Parse the payload sent from the backend
const data = event.data ? event.data.json() : {};
const title = data.title || 'New Notification';
const options = {
body: data.body || 'You have new activity.',
icon: '/icons/icon-192.png',
badge: '/icons/badge.png',
vibrate: [200, 100, 200], // Haptic feedback pattern
data: { url: data.url } // Data to pass to the click handler
};
// Wait until the OS shows the notification
event.waitUntil(
self.registration.showNotification(title, options)
);
});
// Handle user clicking the notification
self.addEventListener('notificationclick', function(event) {
event.notification.close();
event.waitUntil(
clients.openWindow(event.notification.data.url)
);
});