Production-grade guide to progressive web app patterns covering architecture patterns, implementation strategies, testing approaches, and operational best practices for enterprise engineering teams.
Progressive Web Apps (PWAs) deliver app-like experiences over the web, combining the reach of the web with the performance and reliability of native apps. They work offline, load quickly, support push notifications, and can be installed on the user’s device. These patterns are essential when users expect fast, reliable interactions across network conditions—especially on mobile, where connectivity is inconsistent and battery life is precious.
Service workers are the backbone of PWAs. They act as a proxy between the browser and the network, enabling offline operation, background sync, and push notifications. The pattern begins with a service-worker.js script registered in the main HTML document:
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
.then(reg => console.log('Service worker registered:', reg.scope))
.catch(err => console.error('Registration failed:', err));
}
</script>
The service worker script must define a fetch event handler to intercept network requests:
self.addEventListener('fetch', event => {
const url = new URL(event.request.url);
const isApiRequest = url.origin === self.location.origin && url.pathname.startsWith('/api');
event.respondWith(
caches.match(event.request)
.then(cachedResponse => {
if (cachedResponse) {
return cachedResponse;
}
return fetch(event.request)
.then(networkResponse => {
if (networkResponse.ok) {
return caches.open('static').then(cache => {
cache.put(event.request, networkResponse.clone());
return networkResponse;
});
}
return networkResponse;
})
.catch(() => caches.match('/offline.html'));
})
);
});
This pattern ensures that every request is served from the cache if possible. On first load, the app fetches all assets and stores them in the static cache. Subsequent visits are faster because the service worker can serve resources immediately.
Failure mode: The service worker updates but the new version never activates. This happens when the skipWaiting() call is omitted in the install event:
self.addEventListener('install', event => {
event.waitUntil(
caches.open('static').then(cache => {
return cache.addAll([
'/',
'/styles.css',
'/main.js',
'/icons/icon-192.png',
'/offline.html'
]);
})
);
self.skipWaiting(); // Critical: forces new worker to activate immediately
});
Without skipWaiting(), the new service worker waits for the current page to close before activating. Users may see stale content for days.
Different resources benefit from different caching strategies. The pattern of stale-while-revalidate is common for API responses:
self.addEventListener('fetch', event => {
const url = new URL(event.request.url);
const isApiRequest = url.pathname.startsWith('/api');
event.respondWith(
caches.open('api').then(cache => {
return cache.match(event.request)
.then(cachedResponse => {
const networkFetch = fetch(event.request)
.then(networkResponse => {
cache.put(event.request, networkResponse.clone());
return networkResponse;
});
// Return cached response immediately, update in background
return cachedResponse ? Promise.all([cachedResponse, networkFetch]) : networkFetch;
})
.then(([cachedResponse, networkResponse]) => {
return cachedResponse || networkResponse;
});
})
);
});
This allows users to see content instantly while the service worker fetches fresh data in the background. The stale-while-revalidate pattern is ideal for user profiles, feeds, and dashboards.
For static assets like fonts, scripts, and styles, cache-first is preferred:
self.addEventListener('fetch', event => {
const url = new URL(event.request.url);
const isStatic = /\.(js|css|png|jpg|jpeg|svg|webp|woff2?|ttf|eot)$/i.test(url.pathname);
event.respondWith(
caches.match(event.request).then(cached => {
return cached || fetch(event.request).then(response => {
return caches.open('static').then(cache => {
cache.put(event.request, response.clone());
return response;
});
});
})
);
});
Edge case: When a user navigates to a page and the service worker is not yet active, the page may be served from the browser cache but miss the latest assets. To fix this, use clients.claim() in the activate event:
self.addEventListener('activate', event => {
event.waitUntil(
clients.claim()
);
});
This ensures that all pages served from the service worker’s cache are controlled by the new worker, reducing the chance of “phantom” content.
When the network is unreachable, the PWA must provide a coherent experience. The offline.html page is the go-to fallback. It should be self-contained: all assets inlined or cached.
<!-- /offline.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Offline</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<div class="offline">
<h1>Offline</h1>
<p>You’re now using the app without an internet connection.</p>
<button onclick="location.reload()">Try again</button>
</div>
<script src="/main.js"></script>
</body>
</html>
The service worker detects offline state via the navigator.onLine boolean and can update the UI accordingly:
self.addEventListener('online', () => {
const status = document.querySelector('.status');
if (status) {
status.textContent = 'Online';
status.classList.remove('offline');
}
});
self.addEventListener('offline', () => {
const status = document.querySelector('.status');
if (status) {
status.textContent = 'Offline';
status.classList.add('offline');
}
});
Silent failure: The offline page is cached, but the main.js script is not. The offline.html loads, but the “Try again” button does nothing. The fix is to ensure that all critical scripts are explicitly cached in install:
self.addEventListener('install', event => {
event.waitUntil(
caches.open('static').then(cache => {
return cache.addAll([
'/',
'/offline.html',
'/styles.css',
'/main.js',
'/icons/icon-192.png'
]);
})
);
});
Push notifications enable engagement even when the app is closed. The pattern begins with subscribing the user:
async function registerPushSubscription() {
const registration = await navigator.serviceWorker.getRegistration();
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(
'BCg6k8dHfFtZq7r6s5t4u3v2w1x0y9z8a7b6c5d4e3f2g1h0i9j8k7l6m5n4o3p2q1r0s9t8u7v6w5x4y3z2a1b0c9d8e7f6g5h4i3j2k1l0m9n8o7p6q5r4s3t2u1v0w9x8y7z6a5b4c3d2e1f0g9h8i7j6k5l4m3n2o1p0q9r8s7t6u5v4w3x2y1z0'
)
});
const res = await fetch('/api/subscribe', {
method: 'POST',
body: JSON.stringify(subscription),
headers: { 'Content-Type': 'application/json' }
});
if (!res.ok) {
console.error('Failed to register push subscription:', await res.text());
}
}
The applicationServerKey is a public key used to encrypt push messages. The server stores the subscription and sends notifications via the Web Push protocol:
POST /fcm/send HTTP/1.1
Host: fcm.googleapis.com
Authorization: key=YOUR_SERVER_KEY
Content-Type: application/json
{
"to": "YOUR_PUSH_SUBSCRIPTION_ENDPOINT",
"notification": {
"title": "New Message",
"body": "You have a new message from Alice."
},
"data": {
"url": "/messages/123"
}
}
Failure mode: The push event listener is not registered in the service worker, so notifications appear but do nothing. The fix is to attach the handler:
self.addEventListener('push', event => {
const data = event.data.json();
const options = {
body: data.body,
icon: '/icons/icon-192.png',
badge: '/icons/icon-192.png',
data: { url: data.url }
};
event.waitUntil(
self.registration.showNotification(data.title, options)
);
});
self.addEventListener('notificationclick', event => {
const url = event.notification.data.url;
event.notification.close();
event.waitUntil(
clients.matchAll({ type: 'window' }).then(windowClients => {
const existingWindow = windowClients.find(client => client.url === url);
if (existingWindow) {
return existingWindow.focus();
}
return clients.openWindow(url);
})
);
});
The “Add to Home Screen” prompt is triggered when a PWA is installed. The pattern uses the beforeinstallprompt event:
let deferredPrompt;
window.addEventListener('beforeinstallprompt', event => {
deferredPrompt = event;
// Show the install button
const installButton = document.getElementById('install-button');
if (installButton) {
installButton.style.display = 'block';
installButton.onclick = () => {
deferredPrompt.prompt();
deferredPrompt.userChoice.then(choice => {
if (choice.outcome === 'accepted') {
console.log('User accepted the install prompt');
} else {
console.log('User dismissed the install prompt');
}
deferredPrompt = null;
});
};
}
});
// Optional: trigger install on a button click
document.getElementById('install-button').onclick = () => {
if (deferredPrompt) {
deferredPrompt.prompt();
deferredPrompt.userChoice.then(choice => {
if (choice.outcome === 'accepted') {
console.log('User accepted the install prompt');
} else {
console.log('User dismissed the install prompt');
}
deferredPrompt = null;
});
}
};
The beforeinstallprompt event is fired when the browser detects that the PWA is installable. The prompt is not automatic: it must be triggered by the app.
Edge case: The install prompt appears, but the user dismisses it. The app never gets the userChoice object. The solution is to store the prompt and re-activate it when the user returns.
The manifest.json file defines how the PWA appears on the home screen:
{
"name": "My PWA",
"short_name": "PWA",
"description": "A fast, reliable, offline-first web app.",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#000000",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
The display: "standalone" value removes browser UI, making the app feel native. The start_url defines the entry point when the app is launched.
The app shell pattern ensures that the core UI is loaded quickly. The shell includes the main layout, navigation, and initial content. The service worker caches the shell, and the app loads it immediately while fetching data:
self.addEventListener('install', event => {
event.waitUntil(
caches.open('app-shell').then(cache => {
return cache.addAll([
'/',
'/styles.css',
'/main.js',
'/icons/icon-192.png'
]);
})
);
});
self.addEventListener('fetch', event => {
const url = new URL(event.request.url);
// Serve app shell from cache
if (url.pathname === '/') {
event.respondWith(
caches.match('/').then(cached => {
return cached || fetch(event.request);
})
);
}
// Serve other pages from cache or network
else if (url.pathname.startsWith('/page/')) {
event.respondWith(
caches.match(event.request).then(response => {
return response || fetch(event.request);
})
);
}
});
This pattern delivers instant feedback. The user sees the app shell in under 100ms, even on slow networks.
To reduce perceived load time, use rel="preload" and rel="prefetch" in the HTML head:
<link rel="preload" as="font" href="/fonts/inter.woff2" type="font/woff2" crossorigin>
<link rel="prefetch" href="/dashboard" as="document">
<link rel="prefetch" href="/api/user" as="fetch">
The preload directive tells the browser to start fetching critical resources early, while prefetch schedules fetching in the background.
Failure mode: Prefetched resources are not cached by the service worker. The solution is to add a fetch event handler that caches prefetched URLs:
self.addEventListener('fetch', event => {
const url = new URL(event.request.url);
const isPrefetched = url.pathname.startsWith('/api') || url.pathname.startsWith('/page');
event.respondWith(
caches.match(event.request).then(cached => {
return cached || fetch(event.request).then(response => {
return caches.open('prefetch').then(cache => {
cache.put(event.request, response.clone());
return response;
});
});
})
);
});
This ensures that prefetched assets are available on subsequent visits.
This page was rewritten on 18 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy