PWA Patterns
Web App Manifest
{
"name": "My App",
"short_name": "App",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#2563eb",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
],
"screenshots": [
{ "src": "/screenshot-wide.png", "sizes": "1280x720", "form_factor": "wide" }
]
}
Service Worker Registration
// main.js
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const reg = await navigator.serviceWorker.register('/sw.js');
reg.addEventListener('updatefound', () => {
const newSW = reg.installing;
newSW.addEventListener('statechange', () => {
if (newSW.state === 'installed' && navigator.serviceWorker.controller) {
showUpdateBanner(); // tell user to refresh
}
});
});
} catch (err) {
console.error('SW registration failed:', err);
}
});
}
Cache Strategies (Workbox)
// sw.js
import { registerRoute, NavigationRoute } from 'workbox-routing';
import { CacheFirst, NetworkFirst, StaleWhileRevalidate, NetworkOnly } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { precacheAndRoute } from 'workbox-precaching';
// Precache built assets (injected by bundler plugin)
precacheAndRoute(self.__WB_MANIFEST);
// Cache-first: static assets (images, fonts)
registerRoute(
({ request }) => request.destination === 'image',
new CacheFirst({
cacheName: 'images',
plugins: [new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 30 * 24 * 60 * 60 })]
})
);
// Network-first: API calls
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new NetworkFirst({ cacheName: 'api', networkTimeoutSeconds: 3 })
);
// Stale-while-revalidate: HTML pages
registerRoute(
new NavigationRoute(new StaleWhileRevalidate({ cacheName: 'pages' }))
);
Offline Fallback
// sw.js
const OFFLINE_PAGE = '/offline.html';
self.addEventListener('install', e => {
e.waitUntil(caches.open('offline').then(c => c.add(OFFLINE_PAGE)));
});
self.addEventListener('fetch', e => {
if (e.request.mode === 'navigate') {
e.respondWith(
fetch(e.request).catch(() => caches.match(OFFLINE_PAGE))
);
}
});
Install Prompt (A2HS)
let deferredPrompt;
window.addEventListener('beforeinstallprompt', e => {
e.preventDefault();
deferredPrompt = e;
document.getElementById('install-btn').hidden = false;
});
document.getElementById('install-btn').addEventListener('click', async () => {
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
deferredPrompt = null;
document.getElementById('install-btn').hidden = true;
});
Push Notifications
async function subscribePush(vapidPublicKey) {
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey)
});
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(sub)
});
}
// sw.js
self.addEventListener('push', e => {
const data = e.data?.json() ?? {};
e.waitUntil(
self.registration.showNotification(data.title, {
body: data.body, icon: '/icon-192.png', badge: '/badge.png'
})
);
});
Vite PWA Plugin
// vite.config.js
import { VitePWA } from 'vite-plugin-pwa';
export default {
plugins: [VitePWA({
registerType: 'autoUpdate',
workbox: { globPatterns: ['**/*.{js,css,html,svg,png,woff2}'] },
manifest: { name: 'My App', short_name: 'App', theme_color: '#2563eb' }
})]
};