--- name: pwa-development description: Progressive Web Apps - service workers, caching strategies, offline, Workbox --- # PWA Development Skill *Load with: base.md* **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 ```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" } ] } ``` ### Enhanced Manifest (Full Features) ```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"] } } ] } ``` ### Manifest Checklist - [ ] `name` and `short_name` defined - [ ] `start_url` set (use query param for analytics) - [ ] `display` set to `standalone` or `fullscreen` - [ ] Icons: 192x192 and 512x512 minimum - [ ] Maskable icon included for Android adaptive icons - [ ] `theme_color` matches app design - [ ] `background_color` for splash screen - [ ] Screenshots for richer install UI (optional) - [ ] Shortcuts for quick actions (optional) --- ## Service Worker Patterns ### Basic Service Worker ```javascript // 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 ```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); } }); } ``` --- ## 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) ```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; }); }) ); } }); ``` ### Network First (Fresh First) ```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)) ); } }); ``` ### Stale While Revalidate ```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; }); }) ); } }); ``` --- ## Workbox (Recommended) ### Why Workbox? - Battle-tested caching strategies - Precaching with revision management - Background sync for offline forms - Automatic cache cleanup - TypeScript support ### Installation ```bash npm install workbox-webpack-plugin # Webpack npm install @vite-pwa/vite-plugin # Vite ``` ### Workbox with 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 } } } ] } }) ] }; ``` ### Workbox Manual Service Worker ```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] }) ] }) ); ``` --- ## Offline Experience ### Offline Page ```html
Check your connection and try again.