Claude-bootstrap pwa-development
Progressive Web Apps - service workers, caching strategies, offline, Workbox
install
source · Clone the upstream repo
git clone https://github.com/alinaqi/claude-bootstrap
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/alinaqi/claude-bootstrap "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/pwa-development" ~/.claude/skills/alinaqi-claude-bootstrap-pwa-development && rm -rf "$T"
manifest:
skills/pwa-development/SKILL.mdsource content
PWA Development Skill
Purpose: Build Progressive Web Apps that work offline, install like native apps, and deliver fast, reliable experiences across all devices.
Core PWA Requirements
┌─────────────────────────────────────────────────────────────────┐ │ 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 │ └─────────────────────────────────────────────────────────────────┘
Web App Manifest
Required Fields
{ "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" } ] }
Enhanced Manifest (Full Features)
{ "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"] } } ] }
Manifest Checklist
-
andname
definedshort_name -
set (use query param for analytics)start_url -
set todisplay
orstandalonefullscreen - Icons: 192x192 and 512x512 minimum
- Maskable icon included for Android adaptive icons
-
matches app designtheme_color -
for splash screenbackground_color - Screenshots for richer install UI (optional)
- Shortcuts for quick actions (optional)
Service Worker Patterns
Basic Service Worker
// 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')) ); });
Registration
// 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); } }); }
Caching Strategies
Strategy Selection Guide
| 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 |
Cache First (Offline First)
// 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; }); }) ); } });
Network First (Fresh First)
// 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)) ); } });
Stale While Revalidate
// 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; }); }) ); } });
Workbox (Recommended)
Why Workbox?
- Battle-tested caching strategies
- Precaching with revision management
- Background sync for offline forms
- Automatic cache cleanup
- TypeScript support
Installation
npm install workbox-webpack-plugin # Webpack npm install @vite-pwa/vite-plugin # Vite
Workbox with Vite
// 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 } } } ] } }) ] };
Workbox Manual Service Worker
// 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] }) ] }) );
Offline Experience
Offline Page
<!-- 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>
Offline Detection
// 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();
Background Sync (Queue Offline Actions)
// 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' );
// 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.'); } }
App-Like Features
Install Prompt
let 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; });
Detecting Standalone Mode
// 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'); });
Push Notifications
// 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) ); });
Share Target
// 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)}`); })()); } });
Performance Optimization
Critical Rendering Path
<!-- 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>
Image Optimization
<!-- 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>
Code Splitting
// 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; } }
Testing PWA
Lighthouse Audit
# 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
Manual Testing Checklist
-
Installability
- Install prompt appears on desktop Chrome
- Can be added to home screen on mobile
- App opens in standalone mode after install
-
Offline Support
- App loads when offline (airplane mode)
- Cached pages display correctly
- Offline fallback page shows for uncached routes
- Background sync works when coming back online
-
Performance
- First Contentful Paint < 1.8s
- Largest Contentful Paint < 2.5s
- Time to Interactive < 3.8s
- Cumulative Layout Shift < 0.1
-
Service Worker
- SW registers successfully
- Static assets cached on install
- SW updates correctly (new version)
- No stale cache issues
-
Manifest
- All required fields present
- Icons display correctly
- Theme color applied
- Splash screen shows on launch
Testing Service Worker Updates
// 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 Structure
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
Common Mistakes
| Mistake | Fix |
|---|---|
| Missing maskable icon | Add icon with |
| No offline fallback | Create and cache it |
| Cache never expires | Use with Workbox |
| SW caches too aggressively | Use appropriate strategies per resource type |
| No update mechanism | Implement + reload prompt |
| Broken install prompt | Ensure manifest meets all criteria |
| No HTTPS in production | Configure SSL certificate |
| Large cache size | Set and |
| Stale API responses | Use for dynamic data |
| Missing start_url tracking | Add query param: |
PWA Development Checklist
Before Launch
- HTTPS configured (production)
- Manifest complete with all required fields
- Icons in all required sizes (192, 512, maskable)
- Service worker registered and working
- Offline page created and cached
- Cache strategies defined for all resource types
- Install prompt handling implemented
- Lighthouse PWA audit passes
After Launch
- Monitor cache sizes
- Test SW updates don't break app
- Track PWA installs via analytics
- Test on multiple devices/browsers
- Monitor Core Web Vitals
- Set up push notification flow (if needed)
Framework-Specific Guides
Next.js
npm install next-pwa
// next.config.js const withPWA = require('next-pwa')({ dest: 'public', disable: process.env.NODE_ENV === 'development' }); module.exports = withPWA({ // Your Next.js config });
Create React App
# CRA 4+ has PWA support built-in npx create-react-app my-pwa --template cra-template-pwa
Vite (Any Framework)
npm install vite-plugin-pwa -D
See Workbox with Vite section above for configuration.
Quick Reference
Caching Strategy Cheat Sheet
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 Only
Manifest Minimum Requirements
{ "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" } ] }
Service Worker Lifecycle
1. Register → 2. Install → 3. Activate → 4. Fetch ↓ ↓ ↓ ↓ Load app Cache assets Clean old Serve requests caches from cache/network