PWA basics. Making a web app installable, cache a shell for offline use, and survive flaky networks with service workers. Not every site needs a PWA; offline-capable tools and repeat-visit apps benefit most.
What a PWA adds
- A web app manifest – name, icons, theme colour, display mode.
- HTTPS (required for service workers).
- A service worker – cache strategy, offline fallback, background sync optional.
That is enough for ‘Add to Home Screen’ on many devices. Fancy push notifications and background sync are optional extras with more platform baggage.
Web app manifest
// manifest.webmanifest - linked from HTML
{
"name": "Notes",
"short_name": "Notes",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#1a1a2e",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}
Link it in HTML: <link rel="manifest" href="/manifest.webmanifest">. Validate in Chrome DevTools Application tab.
Registering a service worker
// main.js
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const reg = await navigator.serviceWorker.register('/sw.js');
console.log('SW registered', reg.scope);
} catch (err) {
console.error('SW registration failed', err);
}
});
}
Register after load so you do not compete with first paint. Update strategy matters – see below.
Offline shell cache
// sw.js
const CACHE = 'notes-v1';
const SHELL = ['/', '/index.html', '/styles.css', '/js/main.js', '/offline.html'];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE).then((cache) => cache.addAll(SHELL))
);
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))
)
)
);
self.clients.claim();
});
Version the cache name (notes-v1) and delete old caches on activate. Forgetting that step fills storage with stale assets.
Fetch strategies
self.addEventListener('fetch', (event) => {
const { request } = event;
if (request.mode === 'navigate') {
event.respondWith(
fetch(request).catch(() => caches.match('/offline.html'))
);
return;
}
event.respondWith(
caches.match(request).then((cached) => {
if (cached) return cached;
return fetch(request).then((response) => {
if (response.ok && request.method === 'GET') {
const copy = response.clone();
caches.open(CACHE).then((cache) => cache.put(request, copy));
}
return response;
});
})
);
});
Navigation: network first, offline page fallback. Static assets: cache first, network fill-in. API calls often need network-first with local queue – covered in the mini project.
Updating the service worker
Browsers check for a changed sw.js byte-for-byte. Bump CACHE when assets change. Tell the user when a new worker is waiting, then call skipWaiting and reload.
navigator.serviceWorker.addEventListener('controllerchange', () => {
location.reload();
});
async function applyUpdate(registration) {
if (registration.waiting) {
registration.waiting.postMessage({ type: 'SKIP_WAITING' });
}
}
// in sw.js
self.addEventListener('message', (event) => {
if (event.data?.type === 'SKIP_WAITING') self.skipWaiting();
});
Offline data
The shell loads without network; user data lives in IndexedDB or the Cache API. Service workers do not replace a database – they intercept network and cache files. Plan both layers.
Testing offline
- DevTools Application – service worker status, offline checkbox.
- Hard refresh does not unregister the worker – use ‘Update on reload’ while developing.
- Test install flow on a real phone once – desktop alone lies sometimes.
The next tutorial looks at patterns for large front ends – feature folders, shared UI, and consuming a design system without a framework.

