Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Progressive Web Apps - service workers, caching strategies, offline, Workbox
.claude/skills/alinaqi-pwa-development/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-07 | ✗→✓ | ▲ Improved | 681% | 0% |
| case-02 | ✓→✓ | = Same ✓ | 285% | 0% |
| case-01 | ✓→✓ | = Same ✓ | 288% | 0% |
| case-03 | ✓→✓ | = Same ✓ | 868% | 0% |
| case-04 | ✓→✓ | = Same ✓ | 368% | 0% |
Purpose: Build Progressive Web Apps that work offline, install like native apps, and deliver fast, reliable experiences across all devices.
┌─────────────────────────────────────────────────────────────────┐
│ THE THREE PILLARS OF PWA │
│ ───────────────────────────────────────────────────────────── │
│ │
│ 1. HTTPS │
│ Required for service workers and security. │
│ localhost allowed for development. │
│ │
│ 2. SERVICE WORKER │
│ JavaScript that runs in background. │
│ Enables offline, caching, push notifications. │
│ │
│ 3. WEB APP MANIFEST │
│ JSON file describing app metadata. │
│ Enables installation and app-like experience. │
├─────────────────────────────────────────────────────────────────┤
│ INSTALLABILITY CRITERIA (Chrome) │
│ ───────────────────────────────────────────────────────────── │
│ • HTTPS (or localhost) │
│ • Service worker with fetch handler │
│ • Web app manifest with: name, icons (192px + 512px), │
│ start_url, display: standalone/fullscreen/minimal-ui │
└─────────────────────────────────────────────────────────────────┘json{ "name": "My Progressive Web App", "short_name": "MyPWA", "description": "A description of what the app does", "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" }, { "src": "/icons/icon-512-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } ] }
json{ "name": "My Progressive Web App", "short_name": "MyPWA", "description": "A full-featured PWA", "start_url": "/?source=pwa", "scope": "/", "display": "standalone", "orientation": "portrait-primary", "background_color": "#ffffff", "theme_color": "#3367D6", "dir": "ltr", "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" }, { "src": "/icons/icon-384.png", "sizes": "384x384", "type": "image/png" }, { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }, { "src": "/icons/icon-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } ], "screenshots": [ { "src": "/screenshots/desktop.png", "sizes": "1280x720", "type": "image/png", "form_factor": "wide" }, { "src": "/screenshots/mobile.png", "sizes": "750x1334", "type": "image/png", "form_factor": "narrow" } ], "shortcuts": [ { "name": "New Item", "short_name": "New", "description": "Create a new item", "url": "/new?source=shortcut", "icons": [{ "src": "/icons/shortcut-new.png", "sizes": "192x192" }] } ], "share_target": { "action": "/share", "method": "POST", "enctype": "multipart/form-data", "params": { "title": "title", "text": "text", "url": "url", "files": [{ "name": "files", "accept": ["image/*"] }] } }, "protocol_handlers": [ { "protocol": "web+myapp", "url": "/handle?url=%s" } ], "file_handlers": [ { "action": "/open-file", "accept": { "text/plain": [".txt"] } } ] }
name and short_name definedstart_url set (use query param for analytics)display set to standalone or fullscreentheme_color matches app designbackground_color for splash screenjavascript// sw.js const CACHE_NAME = 'app-cache-v1'; const STATIC_ASSETS = [ '/', '/index.html', '/styles/main.css', '/scripts/app.js', '/offline.html' ]; // Install: Cache static assets self.addEventListener('install', (event) => { event.waitUntil( caches.open(CACHE_NAME) .then((cache) => cache.addAll(STATIC_ASSETS)) .then(() => self.skipWaiting()) ); }); // Activate: Clean old caches self.addEventListener('activate', (event) => { event.waitUntil( caches.keys() .then((keys) => Promise.all( keys .filter((key) => key !== CACHE_NAME) .map((key) => caches.delete(key)) )) .then(() => self.clients.claim()) ); }); // Fetch: Serve from cache, fall back to network self.addEventListener('fetch', (event) => { event.respondWith( caches.match(event.request) .then((cached) => cached || fetch(event.request)) .catch(() => caches.match('/offline.html')) ); });
javascript// main.js if ('serviceWorker' in navigator) { window.addEventListener('load', async () => { try { const registration = await navigator.serviceWorker.register('/sw.js', { scope: '/' }); console.log('SW registered:', registration.scope); } catch (error) { console.error('SW registration failed:', error); } }); }
| Strategy | Use Case | Description | |----------|----------|-------------| | Cache First | Static assets (CSS, JS, images) | Check cache, fall back to network | | Network First | API responses, dynamic content | Try network, fall back to cache | | Stale While Revalidate | Semi-static content (avatars, articles) | Serve cache immediately, update in background | | Network Only | Non-cacheable requests (analytics) | Always use network | | Cache Only | Offline-only assets | Only serve from cache |
javascript// Best for: Static assets that rarely change self.addEventListener('fetch', (event) => { if (event.request.destination === 'image' || event.request.destination === 'style' || event.request.destination === 'script') { event.respondWith( caches.match(event.request) .then((cached) => { if (cached) return cached; return fetch(event.request).then((response) => { const clone = response.clone(); caches.open(CACHE_NAME).then((cache) => { cache.put(event.request, clone); }); return response; }); }) ); } });
javascript// Best for: API data, frequently updated content self.addEventListener('fetch', (event) => { if (event.request.url.includes('/api/')) { event.respondWith( fetch(event.request) .then((response) => { const clone = response.clone(); caches.open(CACHE_NAME).then((cache) => { cache.put(event.request, clone); }); return response; }) .catch(() => caches.match(event.request)) ); } });
javascript// Best for: Content that's okay to be slightly outdated self.addEventListener('fetch', (event) => { if (event.request.url.includes('/articles/')) { event.respondWith( caches.open(CACHE_NAME).then((cache) => { return cache.match(event.request).then((cached) => { const fetchPromise = fetch(event.request).then((response) => { cache.put(event.request, response.clone()); return response; }); return cached || fetchPromise; }); }) ); } });
bashnpm install workbox-webpack-plugin # Webpack npm install @vite-pwa/vite-plugin # Vite
javascript// vite.config.js import { VitePWA } from 'vite-plugin-pwa'; export default { plugins: [ VitePWA({ registerType: 'autoUpdate', includeAssets: ['favicon.ico', 'robots.txt', 'apple-touch-icon.png'], manifest: { name: 'My App', short_name: 'App', theme_color: '#ffffff', icons: [ { src: 'pwa-192x192.png', sizes: '192x192', type: 'image/png' }, { src: 'pwa-512x512.png', sizes: '512x512', type: 'image/png' } ] }, workbox: { globPatterns: ['**/*.{js,css,html,ico,png,svg}'], runtimeCaching: [ { urlPattern: /^https:\/\/api\.example\.com\/.*/i, handler: 'NetworkFirst', options: { cacheName: 'api-cache', expiration: { maxEntries: 100, maxAgeSeconds: 60 * 60 * 24 // 24 hours } } }, { urlPattern: /\.(?:png|jpg|jpeg|svg|gif)$/, handler: 'CacheFirst', options: { cacheName: 'image-cache', expiration: { maxEntries: 50, maxAgeSeconds: 60 * 60 * 24 * 30 // 30 days } } } ] } }) ] };
javascript// sw.js import { precacheAndRoute } from 'workbox-precaching'; import { registerRoute } from 'workbox-routing'; import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies'; import { ExpirationPlugin } from 'workbox-expiration'; import { CacheableResponsePlugin } from 'workbox-cacheable-response'; // Precache static assets (generated by build tool) precacheAndRoute(self.__WB_MANIFEST); // Cache images registerRoute( ({ request }) => request.destination === 'image', new CacheFirst({ cacheName: 'images', plugins: [ new CacheableResponsePlugin({ statuses: [0, 200] }), new ExpirationPlugin({ maxEntries: 60, maxAgeSeconds: 30 * 24 * 60 * 60 // 30 days }) ] }) ); // Cache API responses registerRoute( ({ url }) => url.pathname.startsWith('/api/'), new NetworkFirst({ cacheName: 'api-responses', plugins: [ new CacheableResponsePlugin({ statuses: [0, 200] }), new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 24 * 60 * 60 // 24 hours }) ] }) ); // Cache page navigations registerRoute( ({ request }) => request.mode === 'navigate', new NetworkFirst({ cacheName: 'pages', plugins: [ new CacheableResponsePlugin({ statuses: [0, 200] }) ] }) );
html<!-- 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 - App Name</title> <style> body { font-family: system-ui, sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; } .offline-content { text-align: center; padding: 2rem; } .offline-icon { font-size: 4rem; } h1 { color: #333; } p { color: #666; } button { background: #3367D6; color: white; border: none; padding: 0.75rem 1.5rem; border-radius: 4px; cursor: pointer; font-size: 1rem; } </style> </head> <body> <div class="offline-content"> <div class="offline-icon">📡</div> <h1>You're offline</h1> <p>Check your connection and try again.</p> <button onclick="location.reload()">Retry</button> </div> </body> </html>
javascript// Online/offline status handling function updateOnlineStatus() { const status = navigator.onLine ? 'online' : 'offline'; document.body.dataset.connectionStatus = status; if (!navigator.onLine) { showNotification('You are offline. Some features may be unavailable.'); } } window.addEventListener('online', updateOnlineStatus); window.addEventListener('offline', updateOnlineStatus); updateOnlineStatus();
javascript// sw.js with Workbox import { BackgroundSyncPlugin } from 'workbox-background-sync'; import { registerRoute } from 'workbox-routing'; import { NetworkOnly } from 'workbox-strategies'; const bgSyncPlugin = new BackgroundSyncPlugin('formQueue', { maxRetentionTime: 24 * 60 // Retry for 24 hours }); registerRoute( ({ url }) => url.pathname === '/api/submit', new NetworkOnly({ plugins: [bgSyncPlugin] }), 'POST' );
javascript// main.js - Queue form submission async function submitForm(data) { try { const response = await fetch('/api/submit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); return response.json(); } catch (error) { // Will be retried by background sync when online showNotification('Saved offline. Will sync when connected.'); } }
javascriptlet deferredPrompt; window.addEventListener('beforeinstallprompt', (e) => { e.preventDefault(); deferredPrompt = e; showInstallButton(); }); async function installApp() { if (!deferredPrompt) return; deferredPrompt.prompt(); const { outcome } = await deferredPrompt.userChoice; console.log(`User ${outcome === 'accepted' ? 'accepted' : 'dismissed'} install`); deferredPrompt = null; hideInstallButton(); } window.addEventListener('appinstalled', () => { console.log('App installed'); deferredPrompt = null; });
javascript// Check if running as installed PWA function isInstalledPWA() { return window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true; // iOS } // Listen for display mode changes window.matchMedia('(display-mode: standalone)') .addEventListener('change', (e) => { console.log('Display mode:', e.matches ? 'standalone' : 'browser'); });
javascript// Request permission async function requestNotificationPermission() { const permission = await Notification.requestPermission(); if (permission === 'granted') { await subscribeToPush(); } return permission; } // Subscribe to push async function subscribeToPush() { 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', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(subscription) }); } // sw.js - Handle push events self.addEventListener('push', (event) => { 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 } }) ); }); // Handle notification click self.addEventListener('notificationclick', (event) => { event.notification.close(); event.waitUntil( clients.openWindow(event.notification.data.url) ); });
javascript// sw.js - Handle share target self.addEventListener('fetch', (event) => { if (event.request.url.endsWith('/share') && event.request.method === 'POST') { event.respondWith((async () => { const formData = await event.request.formData(); const title = formData.get('title'); const text = formData.get('text'); const url = formData.get('url'); // Store or process shared content // Redirect to app with shared data return Response.redirect(`/?shared=true&title=${encodeURIComponent(title)}`); })()); } });
html<!-- Inline critical CSS --> <style> /* Critical above-the-fold styles */ </style> <!-- Preload important resources --> <link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin> <link rel="preload" href="/scripts/app.js" as="script"> <!-- Defer non-critical CSS --> <link rel="stylesheet" href="/styles/main.css" media="print" onload="this.media='all'"> <noscript><link rel="stylesheet" href="/styles/main.css"></noscript>
html<!-- Responsive images --> <img src="/images/hero-800.webp" srcset=" /images/hero-400.webp 400w, /images/hero-800.webp 800w, /images/hero-1200.webp 1200w " sizes="(max-width: 600px) 400px, (max-width: 1200px) 800px, 1200px" alt="Hero image" loading="lazy" decoding="async" > <!-- Modern formats with fallback --> <picture> <source srcset="/images/hero.avif" type="image/avif"> <source srcset="/images/hero.webp" type="image/webp"> <img src="/images/hero.jpg" alt="Hero image" loading="lazy"> </picture>
javascript// Dynamic imports for route-based splitting const routes = { '/': () => import('./pages/Home.js'), '/about': () => import('./pages/About.js'), '/settings': () => import('./pages/Settings.js') }; async function loadPage(path) { const loader = routes[path]; if (loader) { const module = await loader(); return module.default; } }
bash# Run Lighthouse from CLI npx lighthouse https://your-app.com --view # Key metrics to check: # - PWA badge (installable, offline-ready) # - Performance score # - Best practices # - Accessibility
javascript// Force update check if ('serviceWorker' in navigator) { navigator.serviceWorker.ready.then((registration) => { registration.update(); }); } // Listen for updates navigator.serviceWorker.addEventListener('controllerchange', () => { // New service worker activated window.location.reload(); });
project/
├── public/
│ ├── manifest.json # Web app manifest
│ ├── sw.js # Service worker (if not bundled)
│ ├── offline.html # Offline fallback page
│ ├── robots.txt
│ └── icons/
│ ├── icon-72.png
│ ├── icon-96.png
│ ├── icon-128.png
│ ├── icon-144.png
│ ├── icon-152.png
│ ├── icon-192.png
│ ├── icon-384.png
│ ├── icon-512.png
│ ├── icon-maskable.png # For adaptive icons
│ ├── apple-touch-icon.png
│ └── favicon.ico
├── src/
│ ├── sw.js # Service worker source (if bundled)
│ ├── pwa/
│ │ ├── install.js # Install prompt handling
│ │ ├── offline.js # Offline detection
│ │ └── push.js # Push notification handling
│ └── ...
└── tests/
└── pwa/
├── manifest.test.js
├── sw.test.js
└── offline.test.js| Mistake | Fix | |---------|-----| | Missing maskable icon | Add icon with "purpose": "maskable" | | No offline fallback | Create offline.html and cache it | | Cache never expires | Use ExpirationPlugin with Workbox | | SW caches too aggressively | Use appropriate strategies per resource type | | No update mechanism | Implement skipWaiting() + reload prompt | | Broken install prompt | Ensure manifest meets all criteria | | No HTTPS in production | Configure SSL certificate | | Large cache size | Set maxEntries and maxAgeSeconds | | Stale API responses | Use NetworkFirst for dynamic data | | Missing start_url tracking | Add query param: /?source=pwa |
bashnpm install next-pwa
javascript// next.config.js const withPWA = require('next-pwa')({ dest: 'public', disable: process.env.NODE_ENV === 'development' }); module.exports = withPWA({ // Your Next.js config });
bash# CRA 4+ has PWA support built-in npx create-react-app my-pwa --template cra-template-pwa
bashnpm install vite-plugin-pwa -D
See Workbox with Vite section above for configuration.
Static Assets (CSS, JS, images) → Cache First
API Responses → Network First
User-generated content → Stale While Revalidate
Analytics, non-cacheable → Network Only
Offline-only assets → Cache Onlyjson{ "name": "App Name", "short_name": "App", "start_url": "/", "display": "standalone", "icons": [ { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }, { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" } ] }
1. Register → 2. Install → 3. Activate → 4. Fetch
↓ ↓ ↓ ↓
Load app Cache assets Clean old Serve requests
caches from cache/networkOther measured skills in the registry, with their headline benchmark lift.