Progressive Web Apps
You turn web apps into installable, offline-capable PWAs. Work through the checklist in order — each layer depends on the previous one.
1. Web App Manifest
Create manifest.webmanifest (preferred over .json) and link it:
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#6C3DF4">
Minimum viable manifest:
{
"name": "App Name",
"short_name": "App",
"start_url": "/",
"display": "standalone",
"background_color": "#111117",
"theme_color": "#6C3DF4",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icons/maskable-512.png", "sizes": "512x512",
"type": "image/png", "purpose": "maskable" }
]
}
Rules:
- A maskable icon is required for decent Android install UX. Keep the safe zone: important pixels inside the inner 80% circle.
start_urlmust be cached by the service worker, or offline install checks fail.- iOS ignores most of the manifest: also add
<link rel="apple-touch-icon" href="/icons/icon-180.png">and checkdisplay-mode: standalonevia media query for standalone detection.
2. Service worker: registration
Register late, never block first paint:
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js');
});
}
- Serve
sw.jsfrom the origin root (scope covers the whole app). - Never cache
sw.jsitself with long max-age; the browser revalidates it, but an aggressive CDN header (Cache-Control: max-age=31536000) can freeze users on an old version for a day. Usemax-age=0orno-cachefor the worker file.
3. Caching strategies — pick per resource type
Do NOT use one strategy for everything. Standard split:
| Resource | Strategy |
|---|---|
| App shell (HTML) | Network-first, fallback to cache |
Hashed static assets (app.3f2a1.js) |
Cache-first (immutable) |
| API GET data | Stale-while-revalidate |
| API mutations (POST/PUT) | Network-only (+ background sync queue) |
| Images | Cache-first with size-limited cache |
Skeleton worker without libraries:
const VERSION = 'v3'; // bump on every deploy
const SHELL = ['/', '/index.html', '/offline.html'];
self.addEventListener('install', (e) => {
e.waitUntil(caches.open(VERSION).then((c) => c.addAll(SHELL)));
self.skipWaiting();
});
self.addEventListener('activate', (e) => {
e.waitUntil(caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== VERSION)
.map((k) => caches.delete(k)))));
self.clients.claim();
});
self.addEventListener('fetch', (e) => {
const url = new URL(e.request.url);
if (e.request.method !== 'GET') return;
if (e.request.mode === 'navigate') {
// network-first для навигаций
e.respondWith(
fetch(e.request)
.then((r) => { const copy = r.clone();
caches.open(VERSION).then((c) => c.put(e.request, copy));
return r; })
.catch(() => caches.match(e.request)
.then((r) => r || caches.match('/offline.html'))));
return;
}
// cache-first для остального
e.respondWith(
caches.match(e.request).then((hit) => hit ||
fetch(e.request).then((r) => { const copy = r.clone();
caches.open(VERSION).then((c) => c.put(e.request, copy));
return r; })));
});
For anything bigger, use Workbox (workbox-build /
vite-plugin-pwa) instead of hand-rolling: precache manifest with
revision hashes, registerRoute per strategy. For Vue/Vite projects
vite-plugin-pwa is the default choice — it generates the manifest,
the worker and handles updates.
4. Updates without stuck clients
The classic PWA bug: users stuck on an old version. Always implement:
navigator.serviceWorker.register('/sw.js').then((reg) => {
reg.addEventListener('updatefound', () => {
const w = reg.installing;
w.addEventListener('statechange', () => {
if (w.state === 'installed' && navigator.serviceWorker.controller) {
// новая версия готова — покажи баннер «Обновить»
showRefreshBanner(() => w.postMessage({type: 'SKIP_WAITING'}));
}
});
});
});
navigator.serviceWorker.addEventListener('controllerchange',
() => location.reload());
In the worker: message handler calling self.skipWaiting().
5. Install prompt
let deferred;
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferred = e; // покажи свою кнопку «Установить»
});
async function installApp() {
if (!deferred) return;
deferred.prompt();
await deferred.userChoice; // {outcome: 'accepted'|'dismissed'}
deferred = null;
}
- Chrome/Edge/Android only. On iOS instruct: Share → "Add to Home Screen"; detect iOS Safari and show a hint instead of a button.
- Don't nag: show the button in settings or after a meaningful action.
6. Push notifications (optional layer)
Notification.requestPermission()only from a user gesture.- Subscribe:
registration.pushManager.subscribe({userVisibleOnly: true, applicationServerKey: <VAPID public key>}). - Server sends Web Push (RFC 8291) with the VAPID key pair.
- Worker:
pushevent →self.registration.showNotification();notificationclick→ focus or open the target URL. - iOS ≥ 16.4 supports Web Push only for installed PWAs.
7. Offline data beyond caches
- Cache Storage is for requests; app state lives in IndexedDB.
- Queue mutations offline (IndexedDB outbox) and flush on
onlineor via Background Sync (syncevent, Chromium only — keep the outbox flush on startup as the portable path).
8. Audit checklist (run before shipping)
- Lighthouse → PWA + Performance pass; installability has no errors.
- DevTools → Application → Service Workers: update on reload works, no zombie workers.
- Offline test: DevTools → Network → Offline → reload → app shell and
last data render, navigations hit
offline.htmlfallback. - Install on Android (WebAPK) and iOS (A2HS): icon, splash, standalone without browser chrome.
- Deploy a change → clients get the refresh banner, not a stuck build.
- HTTPS everywhere including redirects (service workers require it).