| name | pwa-development |
| description | Progressive Web Apps - service workers, caching strategies, offline, Workbox |
| when-to-use | When building PWA features - service workers, caching, offline support |
| user-invocable | false |
| paths | ["**/sw.*","**/service-worker.*","**/workbox-config.*","**/manifest.json"] |
| effort | medium |
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
Service Worker Patterns
Basic Service Worker
const CACHE_NAME = "app-cache-v1";
const STATIC_ASSETS = ["/", "/index.html", "/styles/main.css", "/scripts/app.js", "/offline.html"];
self.addEventListener("install", (event) => {
event.waitUntil(
caches
.open(CACHE_NAME)
.then((cache) => cache.addAll(STATIC_ASSETS))
.then(() => self.skipWaiting()),
);
});
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()),
);
});
self.addEventListener("fetch", (event) => {
event.respondWith(
caches
.match(event.request)
.then((cached) => cached || fetch(event.request))
.catch(() => caches.match("/offline.html")),
);
});
Registration
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)
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)
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
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
npm install @vite-pwa/vite-plugin
Workbox with Vite
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,
},
},
},
{
urlPattern: /\.(?:png|jpg|jpeg|svg|gif)$/,
handler: "CacheFirst",
options: {
cacheName: "image-cache",
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24 * 30,
},
},
},
],
},
}),
],
};
Workbox Manual Service Worker
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";
precacheAndRoute(self.__WB_MANIFEST);
registerRoute(
({ request }) => request.destination === "image",
new CacheFirst({
cacheName: "images",
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({
maxEntries: 60,
maxAgeSeconds: 30 * 24 * 60 * 60,
}),
],
}),
);
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,
}),
],
}),
);
registerRoute(
({ request }) => request.mode === "navigate",
new NetworkFirst({
cacheName: "pages",
plugins: [new CacheableResponsePlugin({ statuses: [0, 200] })],
}),
);
Offline Experience
Offline Page
<!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
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)
import { BackgroundSyncPlugin } from "workbox-background-sync";
import { registerRoute } from "workbox-routing";
import { NetworkOnly } from "workbox-strategies";
const bgSyncPlugin = new BackgroundSyncPlugin("formQueue", {
maxRetentionTime: 24 * 60,
});
registerRoute(
({ url }) => url.pathname === "/api/submit",
new NetworkOnly({
plugins: [bgSyncPlugin],
}),
"POST",
);
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) {
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
function isInstalledPWA() {
return (
window.matchMedia("(display-mode: standalone)").matches ||
window.navigator.standalone === true
);
}
window.matchMedia("(display-mode: standalone)").addEventListener("change", (e) => {
console.log("Display mode:", e.matches ? "standalone" : "browser");
});
Push Notifications
async function requestNotificationPermission() {
const permission = await Notification.requestPermission();
if (permission === "granted") {
await subscribeToPush();
}
return permission;
}
async function subscribeToPush() {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
});
await fetch("/api/push/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(subscription),
});
}
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 },
}),
);
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
event.waitUntil(clients.openWindow(event.notification.data.url));
});
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");
return Response.redirect(`/?shared=true&title=${encodeURIComponent(title)}`);
})(),
);
}
});
Performance Optimization
Critical Rendering Path
<style>
</style>
<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/scripts/app.js" as="script" />
<link rel="stylesheet" href="/styles/main.css" media="print" onload="this.media='all'" />
<noscript><link rel="stylesheet" href="/styles/main.css" /></noscript>
Image Optimization
<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"
/>
<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
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
npx lighthouse https://your-app.com --view
Manual Testing Checklist
Testing Service Worker Updates
if ("serviceWorker" in navigator) {
navigator.serviceWorker.ready.then((registration) => {
registration.update();
});
}
navigator.serviceWorker.addEventListener("controllerchange", () => {
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 "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 |
PWA Development Checklist
Before Launch
After Launch
Framework-Specific Guides
Next.js
npm install next-pwa
const withPWA = require("next-pwa")({
dest: "public",
disable: process.env.NODE_ENV === "development",
});
module.exports = withPWA({
});
Create React App
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