|$ curl https://forge-ai.dev/api/markdown?path=docs/web-platform/pwa
$cat docs/progressive-web-apps.md
updated Recently·35 min read·published

Progressive Web Apps

PWAService WorkersJavaScriptIntermediate to Advanced🎯Free Tools
Introduction

Progressive Web Apps (PWAs) are web applications that use modern browser APIs and progressive enhancement strategies to deliver native app-like experiences to users. They are installable, work offline, and can access device features — all without requiring an app store.

A PWA is not a single technology but a set of patterns and best practices built on three pillars: a Web App Manifest for installability, a Service Worker for offline capability and network control, and HTTPS for security. Together they enable push notifications, background sync, and cached asset delivery.

Web App Manifest

The Web App Manifest is a JSON file that tells the browser how the application should behave when installed on a device. It defines the app name, icons, theme colors, display mode, and start URL. Without a valid manifest the browser will not offer the install prompt.

manifest.json
JSON
1{
2 "name": "My Progressive App",
3 "short_name": "MyApp",
4 "description": "A progressive web app with offline support",
5 "start_url": "/",
6 "display": "standalone",
7 "background_color": "#0A0A0A",
8 "theme_color": "#00FF41",
9 "orientation": "any",
10 "scope": "/",
11 "lang": "en",
12 "categories": ["productivity", "utilities"],
13 "icons": [
14 {
15 "src": "/icons/icon-72.png",
16 "sizes": "72x72",
17 "type": "image/png"
18 },
19 {
20 "src": "/icons/icon-96.png",
21 "sizes": "96x96",
22 "type": "image/png"
23 },
24 {
25 "src": "/icons/icon-128.png",
26 "sizes": "128x128",
27 "type": "image/png"
28 },
29 {
30 "src": "/icons/icon-144.png",
31 "sizes": "144x144",
32 "type": "image/png"
33 },
34 {
35 "src": "/icons/icon-192.png",
36 "sizes": "192x192",
37 "type": "image/png",
38 "purpose": "any maskable"
39 },
40 {
41 "src": "/icons/icon-512.png",
42 "sizes": "512x512",
43 "type": "image/png"
44 }
45 ],
46 "screenshots": [
47 {
48 "src": "/screenshots/desktop.png",
49 "sizes": "1280x720",
50 "type": "image/png",
51 "form_factor": "wide",
52 "label": "Desktop view"
53 },
54 {
55 "src": "/screenshots/mobile.png",
56 "sizes": "750x1334",
57 "type": "image/png",
58 "form_factor": "narrow",
59 "label": "Mobile view"
60 }
61 ],
62 "shortcuts": [
63 {
64 "name": "New Task",
65 "short_name": "New",
66 "url": "/new",
67 "icons": [{ "src": "/icons/new-96.png", "sizes": "96x96" }]
68 }
69 ]
70}

info

Always include maskable icons with purpose: "any maskable" so Android can apply adaptive icon masking. Test your manifest with Chrome DevTools under the Application tab for installability issues.
Service Workers

A Service Worker is a script that runs in the background of the browser, separate from the main thread. It intercepts network requests, caches responses, and enables offline functionality. Service Workers are the backbone of any PWA and require HTTPS.

sw.js
JavaScript
1// sw.js — Service Worker registration from main thread
2if ('serviceWorker' in navigator) {
3 window.addEventListener('load', async () => {
4 try {
5 const registration = await navigator.serviceWorker.register('/sw.js', {
6 scope: '/',
7 });
8 console.log('SW registered:', registration.scope);
9
10 registration.addEventListener('updatefound', () => {
11 const newWorker = registration.installing;
12 newWorker.addEventListener('statechange', () => {
13 if (newWorker.state === 'activated') {
14 notifyUser('App updated! Refresh for latest version.');
15 }
16 });
17 });
18 } catch (err) {
19 console.error('SW registration failed:', err);
20 }
21 });
22}
23
24// sw.js — The service worker itself
25const CACHE_NAME = 'app-cache-v1';
26const STATIC_ASSETS = [
27 '/',
28 '/index.html',
29 '/app.js',
30 '/styles.css',
31 '/offline.html',
32];
33
34// Install — pre-cache critical assets
35self.addEventListener('install', (event) => {
36 event.waitUntil(
37 caches.open(CACHE_NAME).then((cache) => {
38 return cache.addAll(STATIC_ASSETS);
39 })
40 );
41 self.skipWaiting();
42});
43
44// Activate — clean old caches
45self.addEventListener('activate', (event) => {
46 event.waitUntil(
47 caches.keys().then((cacheNames) => {
48 return Promise.all(
49 cacheNames
50 .filter((name) => name !== CACHE_NAME)
51 .map((name) => caches.delete(name))
52 );
53 })
54 );
55 self.clients.claim();
56});
57
58// Fetch — intercept all network requests
59self.addEventListener('fetch', (event) => {
60 event.respondWith(
61 caches.match(event.request).then((cached) => {
62 return cached || fetch(event.request).then((response) => {
63 // Don't cache non-GET or opaque responses
64 if (event.request.method !== 'GET' || response.type === 'opaque') {
65 return response;
66 }
67 const clone = response.clone();
68 caches.open(CACHE_NAME).then((cache) => {
69 cache.put(event.request, clone);
70 });
71 return response;
72 });
73 }).catch(() => {
74 // Fallback for offline
75 if (event.request.destination === 'document') {
76 return caches.match('/offline.html');
77 }
78 })
79 );
80});

warning

Service Workers must be served over HTTPS (except localhost). They have a limited lifecycle — installed, activated, and can be terminated at any time. Never store state in global variables; use IndexedDB or Cache API instead.
Caching Strategies

Choosing the right caching strategy determines how your PWA behaves when offline, how fresh content stays, and how fast the app loads. Each strategy trades off freshness, speed, and bandwidth usage differently.

caching-strategies.js
JavaScript
1// Strategy 1: Cache First (best for static assets)
2async function cacheFirst(request) {
3 const cached = await caches.match(request);
4 if (cached) return cached;
5
6 const response = await fetch(request);
7 const cache = await caches.open('static-v1');
8 cache.put(request, response.clone());
9 return response;
10}
11
12// Strategy 2: Network First (best for API data)
13async function networkFirst(request) {
14 try {
15 const response = await fetch(request);
16 const cache = await caches.open('api-v1');
17 cache.put(request, response.clone());
18 return response;
19 } catch {
20 const cached = await caches.match(request);
21 return cached || new Response('{"error":"offline"}', {
22 headers: { 'Content-Type': 'application/json' },
23 });
24 }
25}
26
27// Strategy 3: Stale While Revalidate (best for semi-dynamic)
28async function staleWhileRevalidate(request) {
29 const cache = await caches.open('hybrid-v1');
30 const cached = await cache.match(request);
31
32 const fetchPromise = fetch(request).then((response) => {
33 cache.put(request, response.clone());
34 return response;
35 }).catch(() => cached);
36
37 return cached || fetchPromise;
38}
39
40// Strategy 4: Network Only (for non-cacheable requests)
41async function networkOnly(request) {
42 return fetch(request);
43}
44
45// Strategy 5: Cache Only (for pre-cached assets)
46async function cacheOnly(request) {
47 return caches.match(request);
48}
49
50// Router — apply different strategies per request type
51self.addEventListener('fetch', (event) => {
52 const url = new URL(event.request.url);
53
54 if (event.request.method !== 'GET') return;
55
56 // Static assets — cache first
57 if (url.pathname.match(/\.(js|css|png|jpg|svg|woff2)$/)) {
58 event.respondWith(cacheFirst(event.request));
59 return;
60 }
61
62 // API calls — network first
63 if (url.pathname.startsWith('/api/')) {
64 event.respondWith(networkFirst(event.request));
65 return;
66 }
67
68 // HTML pages — stale while revalidate
69 if (event.request.headers.get('accept')?.includes('text/html')) {
70 event.respondWith(staleWhileRevalidate(event.request));
71 return;
72 }
73
74 event.respondWith(networkOnly(event.request));
75});

best practice

Use Cache First for images, fonts, and static bundles. Use Network First for API data that changes frequently. Use Stale While Revalidate for HTML pages where you want fast loads but also fresh content on the next visit.
Offline Support

True offline support means the app remains functional even without a network connection. This requires pre-caching critical assets, handling failed fetches gracefully, and providing meaningful offline UI states.

offline-support.js
JavaScript
1// Offline page fallback
2const OFFLINE_PAGE = '/offline.html';
3
4self.addEventListener('fetch', (event) => {
5 event.respondWith(
6 fetch(event.request).catch(async () => {
7 // Check if this is a navigation request
8 if (event.request.mode === 'navigate') {
9 const cache = await caches.open('pages-v1');
10 const offlinePage = await cache.match(OFFLINE_PAGE);
11 return offlinePage || new Response(offlineHTML(), {
12 headers: { 'Content-Type': 'text/html' },
13 });
14 }
15 return new Response('Offline', { status: 503 });
16 })
17 );
18});
19
20// Background Sync — queue actions when offline
21self.addEventListener('sync', (event) => {
22 if (event.tag === 'sync-notes') {
23 event.waitUntil(syncPendingNotes());
24 }
25});
26
27async function syncPendingNotes() {
28 const db = await openDB();
29 const tx = db.transaction('pending', 'readwrite');
30 const store = tx.objectStore('pending');
31 const request = store.getAll();
32
33 return new Promise((resolve, reject) => {
34 request.onsuccess = async () => {
35 const notes = request.result;
36 for (const note of notes) {
37 try {
38 await fetch('/api/notes', {
39 method: 'POST',
40 headers: { 'Content-Type': 'application/json' },
41 body: JSON.stringify(note),
42 });
43 store.delete(note.id);
44 } catch {
45 // Will retry on next sync
46 }
47 }
48 resolve();
49 };
50 request.onerror = reject;
51 });
52}
53
54// Queue offline actions from the main thread
55async function queueOfflineAction(action) {
56 const db = await openDB();
57 const tx = db.transaction('pending', 'readwrite');
58 tx.objectStore('pending').put(action);
59
60 // Register sync event
61 const registration = await navigator.serviceWorker.ready;
62 await registration.sync.register('sync-notes');
63}
64
65// Online/Offline status detection
66window.addEventListener('online', () => {
67 document.body.classList.remove('offline');
68 showToast('You are back online');
69});
70
71window.addEventListener('offline', () => {
72 document.body.classList.add('offline');
73 showToast('You are offline — changes will sync later');
74});
75
76// Check connectivity proactively
77function checkConnection() {
78 return navigator.onLine && fetch('/api/ping', {
79 method: 'HEAD',
80 cache: 'no-store',
81 }).then(() => true).catch(() => false);
82}

info

Always show users a clear offline indicator. The navigator.onLine property tells you about the network interface but not actual internet connectivity — combine it with a fetch ping to /api/ping for reliable detection.
Service Worker Lifecycle

Service Workers follow a strict lifecycle: registration, installation, activation, and idle. Understanding this flow is critical for handling updates and cache invalidation correctly.

sw-lifecycle.js
JavaScript
1// Versioned caching for seamless updates
2const CACHE_VERSION = 2;
3const CACHE_NAME = `app-cache-v${CACHE_VERSION}`;
4
5self.addEventListener('install', (event) => {
6 event.waitUntil(
7 caches.open(CACHE_NAME).then(async (cache) => {
8 // Add new assets for this version
9 await cache.addAll([
10 '/',
11 '/index.html',
12 '/app.v2.js',
13 '/styles.v2.css',
14 ]);
15 })
16 );
17 self.skipWaiting(); // Skip waiting to activate immediately
18});
19
20self.addEventListener('activate', (event) => {
21 event.waitUntil(
22 caches.keys().then((names) =>
23 Promise.all(
24 names
25 .filter((name) => {
26 // Parse version number from cache name
27 const match = name.match(/v(\d+)$/);
28 if (!match) return true;
29 return parseInt(match[1]) < CACHE_VERSION;
30 })
31 .map((name) => caches.delete(name))
32 )
33 )
34 );
35 self.clients.claim(); // Take control of all open pages
36});
37
38// Notify clients when update is ready
39self.addEventListener('activate', (event) => {
40 event.waitUntil(
41 self.clients.matchAll().then((clients) => {
42 clients.forEach((client) => {
43 client.postMessage({
44 type: 'SW_UPDATED',
45 version: CACHE_VERSION,
46 });
47 });
48 })
49 );
50});
51
52// Main thread — handle update notifications
53navigator.serviceWorker.addEventListener('message', (event) => {
54 if (event.data.type === 'SW_UPDATED') {
55 showUpdateBanner(event.data.version);
56 }
57});
58
59function showUpdateBanner(version) {
60 const banner = document.createElement('div');
61 banner.className = 'update-banner';
62 banner.innerHTML = `
63 <p>New version (v${version}) available!</p>
64 <button onclick="reloadApp()">Refresh</button>
65 `;
66 document.body.prepend(banner);
67}
68
69function reloadApp() {
70 window.location.reload();
71}

best practice

Use self.skipWaiting() and self.clients.claim() together to make updates take effect immediately. Without them, the old service worker keeps running until all tabs are closed. Always prompt users to reload when an update is available rather than forcing it.
Push Notifications

Push notifications allow PWAs to re-engage users with timely messages even when the app is not open. They require user permission and a push subscription tied to your service worker.

push-notifications.js
JavaScript
1// Main thread — request permission and subscribe
2async function setupPushNotifications() {
3 // Check if push is supported
4 if (!('Notification' in window) || !('serviceWorker' in navigator)) {
5 console.log('Push notifications not supported');
6 return;
7 }
8
9 // Request permission
10 const permission = await Notification.requestPermission();
11 if (permission !== 'granted') {
12 console.log('Notification permission denied');
13 return;
14 }
15
16 // Get service worker registration
17 const registration = await navigator.serviceWorker.ready;
18
19 // Get existing subscription or create new one
20 let subscription = await registration.pushManager.getSubscription();
21 if (!subscription) {
22 const vapidPublicKey = 'YOUR_VAPID_PUBLIC_KEY';
23 subscription = await registration.pushManager.subscribe({
24 userVisibleOnly: true,
25 applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
26 });
27 }
28
29 // Send subscription to your server
30 await fetch('/api/push/subscribe', {
31 method: 'POST',
32 headers: { 'Content-Type': 'application/json' },
33 body: JSON.stringify(subscription),
34 });
35
36 console.log('Push subscription active');
37}
38
39// Convert VAPID key for the browser
40function urlBase64ToUint8Array(base64String) {
41 const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
42 const base64 = (base64String + padding)
43 .replace(/-/g, '+')
44 .replace(/_/g, '/');
45 const rawData = atob(base64);
46 return Uint8Array.from([...rawData].map((c) => c.charCodeAt(0)));
47}
48
49// Service worker — handle incoming push events
50self.addEventListener('push', (event) => {
51 const data = event.data?.json() ?? {
52 title: 'New Notification',
53 body: 'You have a new message',
54 icon: '/icons/icon-192.png',
55 badge: '/icons/badge-72.png',
56 data: { url: '/' },
57 };
58
59 event.waitUntil(
60 self.registration.showNotification(data.title, {
61 body: data.body,
62 icon: data.icon,
63 badge: data.badge,
64 vibrate: [100, 50, 100],
65 data: data.data,
66 actions: [
67 { action: 'open', title: 'Open' },
68 { action: 'dismiss', title: 'Dismiss' },
69 ],
70 })
71 );
72});
73
74// Handle notification clicks
75self.addEventListener('notificationclick', (event) => {
76 event.notification.close();
77 if (event.action === 'dismiss') return;
78
79 event.waitUntil(
80 self.clients.matchAll({ type: 'window' }).then((clients) => {
81 // Focus existing window or open new one
82 for (const client of clients) {
83 if (client.url === event.notification.data.url && 'focus' in client) {
84 return client.focus();
85 }
86 }
87 return self.clients.openWindow(event.notification.data.url);
88 })
89 );
90});

warning

Push notifications require a VAPID key pair and a server endpoint to send pushes. Never send pushes without user consent — it erodes trust and may violate platform policies. On iOS, push notifications require iOS 16.4+ and the app must be installed to the home screen.
Installation & BeforeInstallPrompt

Browsers show an install prompt when the PWA meets criteria: valid manifest, registered service worker, served over HTTPS. You can intercept the prompt with the BeforeInstallPrompt event to customize the install experience.

install-prompt.js
JavaScript
1// Capture the browser's install prompt
2let deferredPrompt;
3
4window.addEventListener('beforeinstallprompt', (event) => {
5 // Prevent the default mini-infobar
6 event.preventDefault();
7 deferredPrompt = event;
8
9 // Show custom install button
10 const installBtn = document.getElementById('install-btn');
11 installBtn.style.display = 'block';
12 installBtn.addEventListener('click', installApp);
13});
14
15async function installApp() {
16 if (!deferredPrompt) return;
17
18 // Show the install prompt
19 deferredPrompt.prompt();
20 const { outcome } = await deferredPrompt.userChoice;
21
22 if (outcome === 'accepted') {
23 console.log('User accepted the install prompt');
24 } else {
25 console.log('User dismissed the install prompt');
26 }
27
28 deferredPrompt = null;
29 document.getElementById('install-btn').style.display = 'none';
30}
31
32// Detect if app is already installed
33window.addEventListener('appinstalled', (event) => {
34 console.log('PWA was installed');
35 deferredPrompt = null;
36 trackInstallation();
37});
38
39// Check standalone mode (running as installed app)
40function isStandalone() {
41 return window.matchMedia('(display-mode: standalone)').matches
42 || window.navigator.standalone === true;
43}
44
45if (isStandalone()) {
46 console.log('Running as installed PWA');
47 // Adjust UI for standalone mode
48 document.body.classList.add('standalone');
49}

info

On iOS there is no BeforeInstallPrompt event. Instead, show instructions telling users to tap the Share button and then "Add to Home Screen". On desktop Chrome and Edge, the installability criteria are automatically detected.
Workbox — Production Service Workers

Workbox is a library from Google that simplifies service worker development. It provides pre-built caching strategies, routing, and tools for precaching — removing the need to write service workers from scratch.

workbox-sw.js
JavaScript
1// workbox-config.js
2module.exports = {
3 globDirectory: 'build/',
4 globPatterns: ['**/*.{js,css,html,png,svg,woff2}'],
5 swDest: 'build/sw.js',
6 runtimeCaching: [
7 {
8 urlPattern: /^https://api\.example\.com\/v1\//,
9 handler: 'NetworkFirst',
10 options: {
11 cacheName: 'api-cache',
12 expiration: {
13 maxEntries: 50,
14 maxAgeSeconds: 300, // 5 minutes
15 },
16 networkTimeoutSeconds: 3,
17 },
18 },
19 {
20 urlPattern: /\.(png|jpg|jpeg|svg|gif|webp)$/,
21 handler: 'CacheFirst',
22 options: {
23 cacheName: 'images',
24 expiration: {
25 maxEntries: 100,
26 maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
27 },
28 },
29 },
30 ],
31};
32
33// sw.js using Workbox
34import { precacheAndRoute } from 'workbox-precaching';
35import { registerRoute } from 'workbox-routing';
36import {
37 CacheFirst,
38 NetworkFirst,
39 StaleWhileRevalidate,
40} from 'workbox-strategies';
41import { ExpirationPlugin } from 'workbox-expiration';
42import { CacheableResponsePlugin } from 'workbox-cacheable-response';
43
44// Precache all build assets
45precacheAndRoute(self.__WB_MANIFEST);
46
47// Cache API responses with network first
48registerRoute(
49 ({ url }) => url.pathname.startsWith('/api/'),
50 new NetworkFirst({
51 cacheName: 'api',
52 plugins: [
53 new ExpirationPlugin({ maxEntries: 50 }),
54 new CacheableResponsePlugin({ statuses: [0, 200] }),
55 ],
56 })
57);
58
59// Cache static assets with cache first
60registerRoute(
61 ({ request }) =>
62 request.destination === 'image' ||
63 request.destination === 'font',
64 new CacheFirst({
65 cacheName: 'static-assets',
66 plugins: [
67 new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 30 * 24 * 3600 }),
68 ],
69 })
70);
71
72// HTML pages with stale while revalidate
73registerRoute(
74 ({ request }) => request.destination === 'document',
75 new StaleWhileRevalidate({ cacheName: 'pages' })
76);
🔥

pro tip

Workbox integrates with webpack, vite, and other bundlers via plugins that automatically generate the precache manifest. This eliminates manual asset lists and ensures cache invalidation happens automatically on each build.
Testing & Debugging

Chrome DevTools provides dedicated panels for debugging PWAs. The Application tab shows service worker status, cache contents, manifest details, and push subscription state. Lighthouse audits score your PWA on installability, performance, and best practices.

pwa-testing.js
JavaScript
1// Debug service worker updates in development
2if (location.hostname === 'localhost') {
3 // Unregister old service workers
4 navigator.serviceWorker.getRegistrations().then((registrations) => {
5 registrations.forEach((reg) => {
6 console.log('Unregistering:', reg.scope);
7 reg.unregister();
8 });
9 });
10
11 // Clear all caches
12 caches.keys().then((names) => {
13 names.forEach((name) => {
14 console.log('Deleting cache:', name);
15 caches.delete(name);
16 });
17 });
18}
19
20// Lighthouse PWA audit checklist
21// 1. HTTPS enabled
22// 2. Valid web app manifest
23// 3. Service worker registered
24// 4. Page responds with 200 when offline
25// 5. Content sized correctly for viewport
26// 6. manifest.json has valid icons
27// 7. viewport meta tag present
28// 8. 200 offline-capable for start_url
29
30// Check PWA criteria programmatically
31async function auditPWA() {
32 const results = {};
33
34 // HTTPS check
35 results.https = location.protocol === 'https:';
36
37 // Service worker check
38 const reg = await navigator.serviceWorker?.getRegistration();
39 results.serviceWorker = !!reg?.active;
40
41 // Manifest check
42 const manifestLink = document.querySelector('link[rel="manifest"]');
43 results.manifest = !!manifestLink;
44
45 if (manifestLink) {
46 const resp = await fetch(manifestLink.href);
47 results.validManifest = resp.ok;
48 const manifest = await resp.json();
49 results.hasIcons = manifest.icons?.length > 0;
50 results.hasName = !!manifest.name;
51 results.hasStartUrl = !!manifest.start_url;
52 }
53
54 // Cache check
55 results.caches = await caches.keys().then((n) => n.length > 0);
56
57 return results;
58}

best practice

Test your PWA in an incognito window to get clean service worker behavior. Use Chrome DevTools Application tab to simulate offline mode, inspect caches, and trigger push notifications. Run Lighthouse with the PWA category enabled for comprehensive auditing.
Key Takeaways
  • A valid manifest.json with icons, name, and start_url is required for installability
  • Service Workers must be registered over HTTPS and follow install/activate/fetch lifecycle
  • Choose caching strategies based on content type: Cache First for static, Network First for APIs
  • Handle offline gracefully with fallback pages and background sync for queued actions
  • Use Workbox for production service workers to avoid manual cache management
  • Always prompt users for notification permission with clear value proposition
  • Test with Chrome DevTools and Lighthouse PWA audits before deploying