Progressive Web Apps close the gap between web and native: offline capability, push notifications, home screen installation, and background sync. The service worker API makes this possible but is notoriously error-prone. Claude Code generates service worker code with correct cache invalidation, proper scope handling, and the update lifecycle that most PWA implementations get wrong.
This guide covers building production PWAs with Claude Code: service worker strategies, push notifications, background sync, and the install experience.
Web App Manifest
Make our web app installable on desktop and mobile.
Include proper icons and splash screen configuration.
// public/manifest.json
{
"name": "MyApp — Task Management",
"short_name": "MyApp",
"description": "Manage tasks and projects, even offline.",
"start_url": "/app",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#ffffff",
"theme_color": "#0ea5e9",
"lang": "en",
"categories": ["productivity", "utilities"],
"icons": [
{ "src": "/icons/icon-72.png", "sizes": "72x72", "type": "image/png" },
{ "src": "/icons/icon-96.png", "sizes": "96x96", "type": "image/png" },
{ "src": "/icons/icon-128.png", "sizes": "128x128", "type": "image/png" },
{ "src": "/icons/icon-144.png", "sizes": "144x144", "type": "image/png" },
{ "src": "/icons/icon-152.png", "sizes": "152x152", "type": "image/png" },
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" },
{ "src": "/icons/icon-384.png", "sizes": "384x384", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
],
"shortcuts": [
{
"name": "New Task",
"short_name": "New Task",
"description": "Create a new task",
"url": "/app/tasks/new",
"icons": [{ "src": "/icons/shortcut-task.png", "sizes": "96x96" }]
}
],
"screenshots": [
{
"src": "/screenshots/desktop-dashboard.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide",
"label": "Dashboard on desktop"
},
{
"src": "/screenshots/mobile-tasks.png",
"sizes": "390x844",
"type": "image/png",
"form_factor": "narrow",
"label": "Task list on mobile"
}
]
}
<!-- In <head> -->
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#0ea5e9" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
Service Worker
Add offline support to the app.
Cache the app shell on install, fetch from cache first for static assets,
and network-first for API calls with offline fallback.
// public/sw.ts (using Workbox via CDN or npm)
import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { BackgroundSyncPlugin } from 'workbox-background-sync';
declare const self: ServiceWorkerGlobalScope & { __WB_MANIFEST: object[] };
// Precache: static assets injected by the build tool (Vite, webpack)
precacheAndRoute(self.__WB_MANIFEST);
cleanupOutdatedCaches();
// Strategy 1: Cache-first for static assets (fonts, images, CSS)
registerRoute(
({ request }) => ['font', 'image', 'style'].includes(request.destination),
new CacheFirst({
cacheName: 'static-assets-v1',
plugins: [
new ExpirationPlugin({
maxEntries: 60,
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
}),
],
}),
);
// Strategy 2: Stale-while-revalidate for read-only API data
registerRoute(
({ url }) => url.pathname.startsWith('/api/') && url.pathname.includes('/read'),
new StaleWhileRevalidate({
cacheName: 'api-cache-v1',
plugins: [
new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 60 * 60 }), // 1 hour
],
}),
);
// Strategy 3: Network-first for mutation APIs
const bgSyncPlugin = new BackgroundSyncPlugin('mutations-queue', {
maxRetentionTime: 24 * 60, // Retry for up to 24 hours (minutes)
});
registerRoute(
({ url, request }) =>
url.pathname.startsWith('/api/') &&
['POST', 'PUT', 'PATCH', 'DELETE'].includes(request.method),
new NetworkFirst({
cacheName: 'api-mutations-v1',
networkTimeoutSeconds: 3,
plugins: [bgSyncPlugin], // Queue mutations if offline
}),
'POST'
);
// Strategy 4: App shell — network-first with offline fallback page
registerRoute(
({ request }) => request.mode === 'navigate',
new NetworkFirst({
cacheName: 'pages-v1',
plugins: [
{
handlerDidError: async () => {
return caches.match('/offline.html');
},
},
],
}),
);
// Handle service worker updates
self.addEventListener('message', (event) => {
if (event.data?.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
Service Worker Registration with Update Handling
// src/service-worker-registration.ts
export async function registerServiceWorker() {
if (!('serviceWorker' in navigator)) return;
try {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/',
});
// Handle updates
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
if (!newWorker) return;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// New version available — show update prompt
showUpdatePrompt(() => {
newWorker.postMessage({ type: 'SKIP_WAITING' });
window.location.reload();
});
}
});
});
// Listen for controller change (happens after skipWaiting)
navigator.serviceWorker.addEventListener('controllerchange', () => {
window.location.reload();
});
console.log('Service worker registered');
} catch (error) {
console.error('Service worker registration failed:', error);
}
}
function showUpdatePrompt(onUpdate: () => void) {
// Show a toast/banner: "New version available" with "Update" button
const event = new CustomEvent('sw-update-available', {
detail: { onUpdate },
});
window.dispatchEvent(event);
}
Push Notifications
Add push notifications to notify users when
a task is assigned to them.
// src/push-notifications.ts
export async function requestPushPermission(): Promise<PushSubscription | null> {
if (!('Notification' in window) || !('PushManager' in window)) {
console.log('Push not supported');
return null;
}
const permission = await Notification.requestPermission();
if (permission !== 'granted') return null;
const registration = await navigator.serviceWorker.ready;
// Subscribe — VAPID public key from your push server
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(
import.meta.env.VITE_VAPID_PUBLIC_KEY
),
});
// Send subscription to your server
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription),
});
return subscription;
}
// Service worker push handler (in sw.ts)
self.addEventListener('push', (event) => {
if (!event.data) return;
const data = event.data.json();
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: '/icons/icon-192.png',
badge: '/icons/badge-72.png',
data: { url: data.url },
actions: [
{ action: 'open', title: 'Open' },
{ action: 'dismiss', title: 'Dismiss' },
],
tag: data.tag ?? 'default',
requireInteraction: data.priority === 'high',
})
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.action === 'dismiss') return;
const url = event.notification.data?.url ?? '/';
event.waitUntil(
self.clients.matchAll({ type: 'window' }).then((clients) => {
// Focus existing window if open
const existingClient = clients.find(c => c.url === url);
if (existingClient) return existingClient.focus();
// Otherwise open new window
return self.clients.openWindow(url);
})
);
});
Install Prompt
Show our own "Install app" button instead of the browser's
mini-infobar. Only show it after the user has used the app
a few times.
// src/pwa-install.ts
let deferredPrompt: BeforeInstallPromptEvent | null = null;
// Capture the install prompt before it fires automatically
window.addEventListener('beforeinstallprompt', (event) => {
event.preventDefault(); // Prevent automatic prompt
deferredPrompt = event as BeforeInstallPromptEvent;
// Show your custom install button/banner
window.dispatchEvent(new CustomEvent('pwa-install-available'));
});
window.addEventListener('appinstalled', () => {
deferredPrompt = null;
// Track installation in analytics
analytics.track('pwa_installed');
});
export async function promptInstall(): Promise<'accepted' | 'dismissed' | 'unavailable'> {
if (!deferredPrompt) return 'unavailable';
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
deferredPrompt = null;
analytics.track('pwa_install_prompt', { outcome });
return outcome;
}
export function isInstalled(): boolean {
return window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator as any).standalone === true;
}
For testing PWA behavior including offline mode and service worker updates, the Playwright E2E guide covers Playwright’s PWA testing APIs. For deploying PWAs to Cloudflare Pages with proper caching headers, the serverless guide covers the _headers configuration. The Claude Skills 360 bundle includes PWA skill sets for service worker strategies and push notification integration. Start with the free tier to generate a service worker for your existing web app.