Progressive Web Apps: Building Offline-Capable Web Applications
Transform web applications into installable, offline-capable experiences with Service Workers, Web App Manifests, and caching strategies. Covers cache patterns, background sync, push notifications, and the performance benefits that make PWAs competitive with native apps.
A Progressive Web App bridges the gap between a website and a native application. It loads instantly on repeat visits because assets are cached locally. It works offline because a service worker intercepts network requests and serves cached responses. It is installable on the home screen because a web app manifest tells the OS how to present it.
The result is a web application that feels native — without the App Store.
The PWA Foundation
Three technologies make a web app “progressive”:
1. Web App Manifest
A JSON file that tells the browser how to install and display the app:
{
"name": "Order Dashboard",
"short_name": "Orders",
"start_url": "/dashboard",
"display": "standalone",
"background_color": "#1a1a2e",
"theme_color": "#e94560",
"icons": [
{ "src": "/icons/192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/512.png", "sizes": "512x512", "type": "image/png" }
]
}
<link rel="manifest" href="/manifest.json">
2. Service Worker
A JavaScript file that runs in a separate thread and intercepts network requests:
// sw.js
const CACHE_NAME = 'v1.2.0';
const PRECACHE_URLS = [
'/',
'/dashboard',
'/styles/main.css',
'/scripts/app.js',
'/icons/192.png',
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS))
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))
)
)
);
});
3. HTTPS
Service workers only run on HTTPS (or localhost for development). This is a hard requirement — no exceptions.
Caching Strategies
The service worker’s fetch handler determines what gets cached and how:
Cache First (Static Assets)
Serve from cache immediately. Fall back to network only if not cached:
self.addEventListener('fetch', (event) => {
if (isStaticAsset(event.request.url)) {
event.respondWith(
caches.match(event.request).then((cached) => {
return cached || fetch(event.request).then((response) => {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
return response;
});
})
);
}
});
Best for: CSS, JavaScript bundles, fonts, images — anything with a versioned filename.
Network First (API Data)
Try the network. Fall back to cache if offline:
if (isApiRequest(event.request.url)) {
event.respondWith(
fetch(event.request)
.then((response) => {
const clone = response.clone();
caches.open('api-cache').then((cache) => cache.put(event.request, clone));
return response;
})
.catch(() => caches.match(event.request))
);
}
Best for: API responses, user-specific data, search results.
Stale While Revalidate (Content)
Serve from cache immediately, then update the cache in the background:
if (isContentPage(event.request.url)) {
event.respondWith(
caches.match(event.request).then((cached) => {
const fetchPromise = fetch(event.request).then((response) => {
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, response.clone()));
return response;
});
return cached || fetchPromise;
})
);
}
Best for: Blog posts, product pages, any content that changes occasionally but is fine to show slightly stale.
Offline Experience Design
Caching strategies handle technical offline capability. UX design handles the human experience.
Offline Indicators
Always communicate connectivity state:
window.addEventListener('online', () => {
document.getElementById('offline-banner').hidden = true;
syncPendingData();
});
window.addEventListener('offline', () => {
document.getElementById('offline-banner').hidden = false;
});
Queuing Offline Actions
When a user performs an action offline (submitting a form, creating a record), queue it and sync when connectivity returns:
async function submitOrder(orderData) {
try {
const response = await fetch('/api/orders', {
method: 'POST',
body: JSON.stringify(orderData),
});
return response.json();
} catch (error) {
// Queue for background sync
await saveToIndexedDB('pending-orders', orderData);
showNotification('Order saved. Will submit when you are back online.');
}
}
Background Sync
The Background Sync API lets the service worker retry failed requests when connectivity returns:
// In the app
navigator.serviceWorker.ready.then((registration) => {
registration.sync.register('sync-orders');
});
// In the service worker
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-orders') {
event.waitUntil(syncPendingOrders());
}
});
async function syncPendingOrders() {
const pending = await getFromIndexedDB('pending-orders');
for (const order of pending) {
await fetch('/api/orders', { method: 'POST', body: JSON.stringify(order) });
await removeFromIndexedDB('pending-orders', order.id);
}
}
Performance Benefits
PWAs deliver measurable performance improvements:
Instant Loading
After the first visit, all static assets are served from the local cache — zero network latency:
First visit: Load time ~2.5s (network)
Repeat visit: Load time ~0.3s (cache)
Reduced Data Usage
Cached assets are not re-downloaded. On mobile networks where bandwidth is metered, this translates directly to cost savings for users.
Reliable on Flaky Networks
Service workers intercept requests before they hit the network. On slow connections, cached responses arrive instantly while the network request runs in the background.
Push Notifications
Push notifications re-engage users even when the browser is closed:
// Request permission
const permission = await Notification.requestPermission();
// Subscribe to push
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
});
// Send subscription to server
await fetch('/api/push/subscribe', {
method: 'POST',
body: JSON.stringify(subscription),
});
Best practice: Request notification permission only after the user has engaged with the app — never on first load.
Anti-Patterns
| Anti-Pattern | Impact | Fix |
|---|---|---|
| Caching everything indefinitely | Stale content, storage bloat | Version caches, expire old versions |
| No offline fallback page | White screen when offline | Cache a custom offline.html |
| Ignoring cache invalidation | Users see outdated app | Update CACHE_NAME with each deploy |
| Permission prompt on first load | Users deny and never see notifications | Ask after meaningful engagement |
| Service worker with no updates | Bug fixes never reach users | Implement skip-waiting + clients.claim |
PWAs are not about checking boxes for installability. They are about delivering a fast, reliable, engaging experience on any device with a browser — and that means thoughtful caching, graceful offline handling, and performance-first architecture.