Progressive web apps use service workers to intercept network requests, cache assets, and enable offline functionality. A service worker is a JavaScript file that runs in a separate thread with no DOM access — it acts as a network proxy between your app and the server. Claude Code implements caching strategies, background sync for deferred writes, push notifications, and the install prompt flow — the parts that make a PWA feel like a native app.
CLAUDE.md for PWA Projects
## PWA Stack
- Vite PWA plugin (vite-plugin-pwa) for service worker generation
- Workbox for caching strategy implementation
- Web Push with VAPID keys for notifications
- Background Sync API for deferred form submissions
- Cache names: app-shell-v1, api-cache-v1, image-cache-v1
- Cache-busting: version embedded in cache name, old caches deleted on activate
- Never cache: /api/auth/*, /api/user/* (personalized), payment endpoints
Service Worker Lifecycle
Implement the service worker with cache versioning.
Old caches should be cleaned up on activation.
// public/sw.ts (or sw.js) — compiled by Vite PWA plugin
/// <reference lib="webworker" />
declare const self: ServiceWorkerGlobalScope;
const CACHE_NAMES = {
shell: 'app-shell-v3', // Bump version to force update
api: 'api-cache-v1',
images: 'image-cache-v1',
};
const SHELL_ASSETS = [
'/',
'/offline.html',
'/src/main.tsx',
'/src/App.tsx',
// Font preloads, critical CSS
];
// Install: cache the app shell immediately
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAMES.shell).then(cache => cache.addAll(SHELL_ASSETS))
.then(() => self.skipWaiting()) // Activate immediately, don't wait for old tabs to close
);
});
// Activate: delete old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then(keys => {
const validCaches = new Set(Object.values(CACHE_NAMES));
return Promise.all(
keys
.filter(key => !validCaches.has(key))
.map(key => caches.delete(key))
);
}).then(() => self.clients.claim()) // Take control of all open tabs
);
});
Caching Strategies
We need different caching strategies for:
- Static assets (JS/CSS): cache-first
- API responses (product list): stale-while-revalidate
- User data: network-first with offline fallback
// Fetch: route requests to the right caching strategy
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// Never cache: auth, payments, personalized data
if (url.pathname.startsWith('/api/auth') || url.pathname.startsWith('/api/user')) {
return; // Falls through to normal network request
}
// Static assets: Cache-First
// JS/CSS have content hashes in filenames — safe to cache forever
if (isStaticAsset(url)) {
event.respondWith(cacheFirst(event.request, CACHE_NAMES.shell));
return;
}
// Product API: Stale-While-Revalidate
// Show cached immediately, update in background
if (url.pathname.startsWith('/api/products')) {
event.respondWith(staleWhileRevalidate(event.request, CACHE_NAMES.api, 60 * 60)); // 1h TTL
return;
}
// Images: Cache-First with 7-day TTL
if (isImage(url)) {
event.respondWith(cacheFirst(event.request, CACHE_NAMES.images, 7 * 24 * 60 * 60));
return;
}
// HTML navigation: Network-First with offline fallback
if (event.request.mode === 'navigate') {
event.respondWith(networkFirstWithFallback(event.request));
return;
}
});
// Cache-First: return cached, fall back to network + cache
async function cacheFirst(request: Request, cacheName: string, maxAgeSeconds?: number): Promise<Response> {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
if (cached) {
if (maxAgeSeconds) {
const cachedTime = new Date(cached.headers.get('date') ?? 0).getTime();
if (Date.now() - cachedTime < maxAgeSeconds * 1000) return cached;
} else {
return cached;
}
}
const response = await fetch(request);
if (response.ok) cache.put(request, response.clone());
return response;
}
// Stale-While-Revalidate: return cached immediately, update in background
async function staleWhileRevalidate(request: Request, cacheName: string, maxAgeSeconds: number): Promise<Response> {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
// Revalidate in background regardless
const fetchPromise = fetch(request).then(response => {
if (response.ok) cache.put(request, response.clone());
return response;
});
if (cached) {
const cachedTime = new Date(cached.headers.get('date') ?? 0).getTime();
const isStale = Date.now() - cachedTime > maxAgeSeconds * 1000;
if (!isStale) return cached;
// If stale, return cached but wait for fresh in background
// Could notify the client here via postMessage
}
return fetchPromise;
}
// Network-First: try network, fall back to cache, then offline page
async function networkFirstWithFallback(request: Request): Promise<Response> {
try {
const response = await fetch(request);
const cache = await caches.open(CACHE_NAMES.shell);
cache.put(request, response.clone());
return response;
} catch {
const cached = await caches.match(request);
if (cached) return cached;
// Return offline page for navigation requests
const offlinePage = await caches.match('/offline.html');
return offlinePage ?? new Response('Offline', { status: 503 });
}
}
function isStaticAsset(url: URL) {
return /\.(js|css|woff2|ico)$/.test(url.pathname) || url.pathname.startsWith('/assets/');
}
function isImage(url: URL) {
return /\.(png|jpg|jpeg|webp|avif|svg)$/.test(url.pathname);
}
Background Sync: Deferred Writes
Users fill out a form while offline. Submit when they reconnect.
// Register a sync when submitting while offline
async function submitOrder(orderData: Order) {
try {
// Try immediate submission
const result = await fetch('/api/orders', {
method: 'POST',
body: JSON.stringify(orderData),
headers: { 'Content-Type': 'application/json' },
});
return result.json();
} catch {
// Store for background sync
await savePendingOrder(orderData);
const registration = await navigator.serviceWorker.ready;
await registration.sync.register('sync-orders');
return { queued: true };
}
}
// Store pending submissions in IndexedDB
async function savePendingOrder(order: Order) {
const db = await openDB('app-store', 1, {
upgrade(db) {
db.createObjectStore('pending-orders', { keyPath: 'id' });
},
});
await db.put('pending-orders', {
...order,
id: crypto.randomUUID(),
queuedAt: new Date().toISOString(),
});
}
// In the service worker: handle sync event
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-orders') {
event.waitUntil(syncPendingOrders());
}
});
async function syncPendingOrders() {
const db = await openDB('app-store', 1);
const pending = await db.getAll('pending-orders');
for (const order of pending) {
try {
const response = await fetch('/api/orders', {
method: 'POST',
body: JSON.stringify(order),
headers: { 'Content-Type': 'application/json' },
});
if (response.ok) {
await db.delete('pending-orders', order.id);
// Notify the client tabs that the order synced
const clients = await self.clients.matchAll();
clients.forEach(client => {
client.postMessage({ type: 'order-synced', orderId: order.id });
});
}
} catch {
// Will retry on next sync event
throw new Error('Sync failed — will retry');
}
}
}
Push Notifications
Send push notifications when an order ships.
VAPID keys required.
// Generate VAPID keys once (store securely):
// npx web-push generate-vapid-keys
// server/push.ts
import webpush from 'web-push';
webpush.setVapidDetails(
'mailto:[email protected]',
process.env.VAPID_PUBLIC_KEY!,
process.env.VAPID_PRIVATE_KEY!,
);
export async function sendShippingNotification(userId: string, orderId: string) {
const subscription = await db('push_subscriptions').where('user_id', userId).first();
if (!subscription) return;
const payload = JSON.stringify({
title: 'Your order shipped!',
body: `Order #${orderId.slice(-6)} is on its way.`,
url: `/orders/${orderId}`,
icon: '/icons/icon-192.png',
badge: '/icons/badge-72.png',
});
try {
await webpush.sendNotification(JSON.parse(subscription.endpoint_json), payload);
} catch (err: any) {
if (err.statusCode === 410) {
// Subscription expired — remove it
await db('push_subscriptions').where('user_id', userId).delete();
}
}
}
// Browser: subscribe to push
async function subscribeToPush() {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true, // Required: every push must show a notification
applicationServerKey: import.meta.env.VITE_VAPID_PUBLIC_KEY,
});
await fetch('/api/push/subscribe', {
method: 'POST',
body: JSON.stringify(subscription),
headers: { 'Content-Type': 'application/json' },
});
}
// Service worker: handle push message
self.addEventListener('push', (event) => {
const data = event.data?.json();
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: data.icon,
badge: data.badge,
data: { url: data.url },
})
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
event.waitUntil(
self.clients.openWindow(event.notification.data.url)
);
});
Install Prompt
// Capture and re-show the browser install prompt
let deferredInstallPrompt: any = null;
window.addEventListener('beforeinstallprompt', (event) => {
event.preventDefault();
deferredInstallPrompt = event;
// Show your custom "Install App" button
document.getElementById('install-btn')?.removeAttribute('hidden');
});
async function promptInstall() {
if (!deferredInstallPrompt) return;
deferredInstallPrompt.prompt();
const { outcome } = await deferredInstallPrompt.userChoice;
if (outcome === 'accepted') {
console.log('PWA installed');
}
deferredInstallPrompt = null;
document.getElementById('install-btn')?.setAttribute('hidden', '');
}
For the React component patterns used in the PWA UI, see the React guide. For Next.js SSR with PWA, the Next.js App Router guide covers the Workbox integration. The Claude Skills 360 bundle includes PWA skill sets covering caching strategies, background sync patterns, and push notification implementation. Start with the free tier to try service worker generation.