Getting started
Introduction
Progressive Web Apps (PWAs) are web applications that use modern web capabilities to deliver an app-like experience. They work offline, can be installed on devices, and provide features like push notifications.
Basic PWA checklist
- Web App Manifest (
manifest.json) - Service Worker for offline support
- HTTPS (required for service workers)
- Icons (192x192 and 512x512 minimum)
- Responsive design
- Fast loading performance
Quick setup
<!DOCTYPE html>
<html>
<head>
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#3367D6" />
</head>
<body>
<script>
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js");
}
</script>
</body>
</html>
Web App Manifest
Core properties
| Property | Type | Description |
|---|---|---|
name |
String | Full app name (used when installed) |
short_name |
String | Short app name (home screen) |
icons |
Array | Image objects with src, sizes, type |
start_url |
String | URL to open when app launches |
display |
String | Display mode (see below) |
background_color |
String | Splash screen background |
theme_color |
String | Browser UI theme color |
description |
String | App description (max 300 chars) |
scope |
String | Navigation scope |
Basic manifest.json
{
"name": "Weather App",
"short_name": "Weather",
"start_url": "/?source=pwa",
"display": "standalone",
"background_color": "#3367D6",
"theme_color": "#3367D6",
"description": "Weather forecast information",
"icons": [
{
"src": "/images/icons-192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "/images/icons-512.png",
"type": "image/png",
"sizes": "512x512"
}
]
}
Display modes
| Mode | Description | Fallback |
|---|---|---|
fullscreen |
Full screen without browser UI | standalone |
standalone |
Standalone window, no browser UI | minimal-ui |
minimal-ui |
Minimal browser controls | browser |
browser |
Standard browser tab | N/A |
Link manifest in HTML
<link rel="manifest" href="/manifest.json" />
Icon requirements
{
"icons": [
{
"src": "/icon-192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "/icon-512.png",
"type": "image/png",
"sizes": "512x512"
},
{
"src": "/icon-maskable.png",
"type": "image/png",
"sizes": "512x512",
"purpose": "any maskable"
}
]
}
Purpose values: any, maskable, any maskable
Advanced properties
{
"id": "com.example.weather",
"orientation": "portrait",
"screenshots": [
{
"src": "/screenshot1.png",
"sizes": "1280x720",
"type": "image/png"
}
],
"shortcuts": [
{
"name": "Today's Weather",
"url": "/today",
"icons": [{ "src": "/today-icon.png", "sizes": "96x96" }]
}
]
}
Service Workers
Registration
if ("serviceWorker" in navigator) {
navigator.serviceWorker
.register("/sw.js", {
scope: "/",
})
.then((reg) => {
console.log("SW registered:", reg);
})
.catch((err) => {
console.log("SW registration failed:", err);
});
}
Lifecycle events
| Event | Description | When It Fires |
|---|---|---|
install |
First installation | New SW downloaded |
activate |
SW activated | After install, no pages using old SW |
fetch |
Network request intercepted | Every resource request |
message |
Message from client | postMessage() called |
push |
Push notification received | Server sends push |
sync |
Background sync | Network available after offline |
Install event (precaching)
const CACHE_NAME = "my-app-v1";
const urlsToCache = ["/", "/styles/main.css", "/script/main.js"];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(urlsToCache)),
);
});
Activate event (cleanup)
self.addEventListener("activate", (event) => {
const cacheWhitelist = ["my-app-v2"];
event.waitUntil(
caches.keys().then((cacheNames) =>
Promise.all(
cacheNames.map((name) => {
if (!cacheWhitelist.includes(name)) {
return caches.delete(name);
}
}),
),
),
);
});
Fetch event (basic)
self.addEventListener("fetch", (event) => {
event.respondWith(
caches.match(event.request).then((response) => {
return response || fetch(event.request);
}),
);
});
Skip waiting
self.addEventListener("install", (event) => {
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(clients.claim());
});
Caching Strategies
Cache API methods
| Method | Description |
|---|---|
caches.open(name) |
Open/create cache |
cache.addAll(requests) |
Fetch and cache multiple |
cache.put(request, response) |
Store request/response pair |
cache.match(request) |
Find cached response |
cache.delete(request) |
Remove cached response |
cache.keys() |
Get all cached requests |
Cache First
Best for static assets (CSS, JS, images).
self.addEventListener("fetch", (event) => {
event.respondWith(
caches.match(event.request).then((cached) => {
if (cached) return cached;
return fetch(event.request).then((response) => {
return caches.open("v1").then((cache) => {
cache.put(event.request, response.clone());
return response;
});
});
}),
);
});
Network First
Best for HTML pages and API requests.
self.addEventListener("fetch", (event) => {
event.respondWith(
fetch(event.request)
.then((response) => {
return caches.open("v1").then((cache) => {
cache.put(event.request, response.clone());
return response;
});
})
.catch(() => caches.match(event.request)),
);
});
Stale-While-Revalidate
Best for non-critical assets.
self.addEventListener("fetch", (event) => {
event.respondWith(
caches.open("v1").then((cache) =>
cache.match(event.request).then((cached) => {
const fetchPromise = fetch(event.request).then((response) => {
cache.put(event.request, response.clone());
return response;
});
return cached || fetchPromise;
}),
),
);
});
Cache with timeout
function timeout(ms, promise) {
return new Promise((resolve, reject) => {
setTimeout(() => reject(new Error("timeout")), ms);
promise.then(resolve, reject);
});
}
self.addEventListener("fetch", (event) => {
event.respondWith(
timeout(5000, fetch(event.request)).catch(() =>
caches.match(event.request),
),
);
});
Network Only
self.addEventListener("fetch", (event) => {
event.respondWith(fetch(event.request));
});
Cache Only
self.addEventListener("fetch", (event) => {
event.respondWith(caches.match(event.request));
});
Workbox
Quick setup
importScripts(
"https://storage.googleapis.com/workbox-cdn/releases/6.5.4/workbox-sw.js",
);
workbox.precaching.precacheAndRoute([
{ url: "/index.html", revision: "383676" },
{ url: "/styles/app.css", revision: "e28a9b" },
]);
workbox.routing.registerRoute(
({ request }) => request.destination === "image",
new workbox.strategies.CacheFirst({
cacheName: "images",
plugins: [
new workbox.expiration.ExpirationPlugin({
maxEntries: 60,
maxAgeSeconds: 30 * 24 * 60 * 60,
}),
],
}),
);
Workbox strategies
| Strategy | Class | Use Case |
|---|---|---|
| Cache First | CacheFirst() |
Static assets |
| Network First | NetworkFirst() |
API, HTML |
| Stale-While-Revalidate | StaleWhileRevalidate() |
Non-critical |
| Network Only | NetworkOnly() |
Always fresh |
| Cache Only | CacheOnly() |
Offline-only |
Route matching
// By destination
workbox.routing.registerRoute(
({ request }) => request.destination === "image",
new workbox.strategies.CacheFirst(),
);
// By URL pattern
workbox.routing.registerRoute(
/\.(?:png|jpg|jpeg|svg|gif)$/,
new workbox.strategies.CacheFirst(),
);
// By string match
workbox.routing.registerRoute("/api/", new workbox.strategies.NetworkFirst());
Expiration plugin
new workbox.strategies.CacheFirst({
cacheName: "images",
plugins: [
new workbox.expiration.ExpirationPlugin({
maxEntries: 60,
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
purgeOnQuotaError: true,
}),
],
});
Background sync
const bgSyncPlugin = new workbox.backgroundSync.BackgroundSyncPlugin(
"myQueueName",
{ maxRetentionTime: 24 * 60 },
);
workbox.routing.registerRoute(
/\/api\/.*\/*.json/,
new workbox.strategies.NetworkOnly({
plugins: [bgSyncPlugin],
}),
"POST",
);
App Installation
beforeinstallprompt event
let deferredPrompt;
window.addEventListener("beforeinstallprompt", (e) => {
// Prevent the mini-infobar from appearing
e.preventDefault();
// Stash the event for later use
deferredPrompt = e;
// Show custom install button
showInstallButton();
});
Custom install button
installButton.addEventListener("click", async () => {
if (!deferredPrompt) {
return;
}
// Show the install prompt
deferredPrompt.prompt();
// Wait for the user's response
const { outcome } = await deferredPrompt.userChoice;
console.log(`User ${outcome} the install prompt`);
// Clear the deferredPrompt
deferredPrompt = null;
});
Detect if installed
function getPWADisplayMode() {
const isStandalone = window.matchMedia("(display-mode: standalone)").matches;
if (isStandalone) {
return "standalone";
}
const isFullscreen = window.matchMedia("(display-mode: fullscreen)").matches;
if (isFullscreen) {
return "fullscreen";
}
return "browser";
}
if (getPWADisplayMode() === "standalone") {
console.log("App is installed");
}
appinstalled event
window.addEventListener("appinstalled", () => {
console.log("PWA was installed");
// Hide install button
hideInstallButton();
// Track installation
analytics.track("pwa_installed");
});
Check if installable
if ("BeforeInstallPromptEvent" in window) {
console.log("App can be installed");
}
Push Notifications
Request permission
const permission = await Notification.requestPermission();
if (permission === "granted") {
console.log("Notification permission granted");
} else {
console.log("Notification permission denied");
}
Show notification from SW
self.registration.showNotification("Title", {
body: "Body text",
icon: "/icon.png",
badge: "/badge.png",
vibrate: [200, 100, 200],
actions: [
{ action: "view", title: "View" },
{ action: "close", title: "Close" },
],
});
Notification options
| Option | Type | Description |
|---|---|---|
body |
String | Notification body text |
icon |
String | Large icon URL |
badge |
String | Small icon for status bar |
image |
String | Large image URL |
vibrate |
Array | Vibration pattern [ms] |
actions |
Array | Action buttons |
tag |
String | Notification identifier |
data |
Any | Custom data |
requireInteraction |
Boolean | Don't auto-dismiss |
silent |
Boolean | No sound/vibration |
Push event handler
self.addEventListener("push", (event) => {
const data = event.data.json();
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: data.icon,
data: { url: data.url },
}),
);
});
Notification click handler
self.addEventListener("notificationclick", (event) => {
event.notification.close();
event.waitUntil(clients.openWindow(event.notification.data.url));
});
Subscribe to push
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
});
// Send subscription to server
await fetch("/api/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(subscription),
});
Get subscription
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
console.log("Subscribed:", subscription);
} else {
console.log("Not subscribed");
}
Background Sync
Register sync
const registration = await navigator.serviceWorker.ready;
await registration.sync.register("sync-messages");
Handle sync in SW
self.addEventListener("sync", (event) => {
if (event.tag === "sync-messages") {
event.waitUntil(sendMessagesToServer());
}
});
async function sendMessagesToServer() {
const messages = await getMessagesFromDB();
for (const message of messages) {
await fetch("/api/messages", {
method: "POST",
body: JSON.stringify(message),
});
}
}
Check sync support
if ("sync" in registration) {
console.log("Background sync supported");
}
Get sync registrations
const tags = await registration.sync.getTags();
console.log("Active sync tags:", tags);
Meta Tags
Essential meta tags
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#3367D6" />
<meta name="description" content="App description" />
iOS meta tags
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
<meta name="apple-mobile-web-app-title" content="App Name" />
<link rel="apple-touch-icon" href="/icon-192.png" />
Status bar styles: default, black, black-translucent
Windows meta tags
<meta name="msapplication-TileImage" content="/icon-144.png" />
<meta name="msapplication-TileColor" content="#3367D6" />
All PWA meta tags
<!-- Viewport -->
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- Theme -->
<meta name="theme-color" content="#3367D6" />
<!-- iOS -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
<meta name="apple-mobile-web-app-title" content="Weather App" />
<link rel="apple-touch-icon" href="/icon-192.png" />
<!-- Windows -->
<meta name="msapplication-TileImage" content="/icon-144.png" />
<meta name="msapplication-TileColor" content="#3367D6" />
<!-- Manifest -->
<link rel="manifest" href="/manifest.json" />
Vite PWA Plugin
Installation
npm install -D vite-plugin-pwa
Basic configuration
import { VitePWA } from "vite-plugin-pwa";
export default {
plugins: [
VitePWA({
registerType: "autoUpdate",
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",
},
],
},
}),
],
};
With Workbox
VitePWA({
registerType: "autoUpdate",
workbox: {
globPatterns: ["**/*.{js,css,html,ico,png,svg}"],
runtimeCaching: [
{
urlPattern: /^https:\/\/api\.example\.com\/.*/i,
handler: "NetworkFirst",
options: {
cacheName: "api-cache",
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60, // 1 hour
},
},
},
],
},
});
Prompt for update
VitePWA({
registerType: "prompt",
manifest: {
/* ... */
},
});
// In your app
import { useRegisterSW } from "virtual:pwa-register/react";
function App() {
const {
needRefresh: [needRefresh, setNeedRefresh],
updateServiceWorker,
} = useRegisterSW();
return (
<>
{needRefresh && (
<button onClick={() => updateServiceWorker(true)}>
Update available - Click to refresh
</button>
)}
</>
);
}
Debugging & Testing
Chrome DevTools
Application tab > Service Workers:
- View registered service workers
- Unregister service workers
- Update on reload
- Skip waiting
- Offline mode
Application tab > Cache Storage:
- View cached resources
- Delete individual caches
- Clear all caches
Lighthouse:
- Run PWA audit
- Check installability
- Performance metrics
Offline testing
// In DevTools Console
navigator.serviceWorker.controller
? console.log("SW active")
: console.log("No SW");
// Check cache
caches.keys().then(console.log);
// Check specific cache
caches.open("v1").then((cache) => cache.keys().then(console.log));
Force update SW
navigator.serviceWorker.getRegistration().then((reg) => reg.update());
Unregister SW
navigator.serviceWorker.getRegistration().then((reg) => reg.unregister());
Gotchas
- HTTPS required: Service workers only work over HTTPS (localhost is exempt for development)
- Cache API vs HTTP cache: Cache API is separate from browser HTTP cache headers
- Service worker scope: SW can only control pages at or below its scope (default: location of SW file)
- Icon requirements: Must provide at least 192x192 and 512x512 PNG icons for installability
- Notification permission: Must be explicitly requested; can't be done in service worker context
- SW updates: Browser checks for SW updates on navigation, but may take 24 hours to update
- Cache invalidation: Must manually delete old caches in
activateevent - No DOM access: Service workers run in a separate thread with no access to DOM
- Manifest scope: Navigation outside manifest scope opens in browser
- iOS limitations: iOS Safari has limited PWA support (no push notifications until iOS 16.4)
Also see
- MDN Web App Manifest - Complete manifest specification
- MDN Service Worker API - Service worker reference
- Chrome Workbox - Official Workbox documentation
- web.dev PWA - Progressive Web Apps learning path
- Vite PWA - Vite PWA plugin documentation