Ch21.2: How to Write a PWA
1. What a PWA Is
A Progressive Web App is a website with three additions:
- a web app manifest that describes the name, icons, start URL, display mode and theme;
- a service worker, a JavaScript worker that sits between the page and the network and can cache assets for offline use;
- HTTPS, so the service worker can be trusted.
That is the whole platform requirement. There is no SDK, no store account and no native project. If the browser supports it, the app is installable; if it does not, the same URL still works as an ordinary website.
2. What You Need to Know
You need basic HTML, CSS and JavaScript, plus the web app manifest. You do not need a native UI toolkit, a platform language or a cross-platform framework. The UI is HTML and CSS; the logic is JavaScript or WebAssembly; the manifest and the service worker are JSON and JavaScript.
That is a much smaller prerequisite list than "learn Android and iOS", and it is the reason
the web is the practical cross-platform target. If you can read a web page's source and
understand a fetch call, you can build a PWA.
It is also one of the easiest things to delegate to an AI assistant. The pieces are standard, well documented and self-contained: a manifest, a service worker, a registration script, an install button and a responsive page. AI is extremely good at writing PWAs; it can produce a working scaffold in one pass. Use it for the scaffold, but verify the two things that matter yourself: that the app is actually installable, and that it actually works offline. Those are behaviors, not text.
3. The Manifest
The manifest is a JSON file linked from the page:
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#111111">
<link rel="apple-touch-icon" href="/icons/192.png">
A minimal manifest that satisfies installability looks like this:
{
"id": "/notes/",
"name": "Notes",
"short_name": "Notes",
"start_url": "/notes/",
"scope": "/notes/",
"display": "standalone",
"display_override": ["window-controls-overlay", "standalone"],
"background_color": "#111111",
"theme_color": "#111111",
"description": "A minimal offline notes app.",
"icons": [
{ "src": "/icons/192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icons/maskable-512.png", "sizes": "512x512",
"type": "image/png", "purpose": "maskable" }
]
}
The fields that matter for installability are name or
short_name, start_url, display (or
display_override), and icons at 192 and 512 pixels. Add
scope so the app does not claim the whole origin, and id so the
browser can keep the app identity stable if start_url changes later.
display: standalone hides the browser chrome. display_override
lets a browser choose a richer mode such as window-controls-overlay on desktop
while falling back to standalone. Add screenshots if you want a
richer install UI, and shortcuts, share_target or
file_handlers only when the app actually implements them.
4. The Service Worker
The service worker is the part that makes the app work offline. It has three lifecycle
events: install, activate and fetch. A small
cache-first worker:
const VERSION = "v3";
const CACHE = `notes-${VERSION}`;
const SHELL = [
"/notes/",
"/notes/app.js",
"/notes/style.css",
"/notes/manifest.webmanifest",
"/notes/icons/192.png"
];
self.addEventListener("install", (event) => {
event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(SHELL)));
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((key) => key !== CACHE)
.map((key) => caches.delete(key)))
)
);
});
self.addEventListener("fetch", (event) => {
const { request } = event;
if (request.method !== "GET") return;
event.respondWith(
caches.match(request).then((cached) => {
if (cached) return cached;
return fetch(request).then((response) => {
if (response.ok && new URL(request.url).origin === location.origin) {
const copy = response.clone();
caches.open(CACHE).then((cache) => cache.put(request, copy));
}
return response;
});
})
);
});
Three rules make the difference between a demo and a working app:
-
Version the cache. Bump
VERSIONwhenever the shell changes, or returning users keep the old files. - Choose a strategy per resource. Cache-first for the app shell; network-first for APIs and anything that must be fresh; stale-while-revalidate for content that can be slightly old.
- Do not cache everything. Third-party responses, opaque cross-origin responses and large media should not be blindly put in the cache.
The service worker is also the update mechanism. A new worker installs in the background and
waits. You can call self.skipWaiting() to activate it immediately, and
self.clients.claim() to take control of open pages. The safer pattern is to ask
the user before reloading, which is what this tutorial's
sw-register.js does.
5. Registration and Install UX
Register the worker from the page:
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker
.register("/notes/sw.js", { scope: "/notes/" })
.catch((error) => console.error("SW registration failed", error));
});
}
On Chromium, beforeinstallprompt lets you show your own install button instead
of relying on the browser's UI:
let deferredPrompt;
window.addEventListener("beforeinstallprompt", (event) => {
event.preventDefault();
deferredPrompt = event;
document.getElementById("install").hidden = false;
});
document.getElementById("install").addEventListener("click", async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt();
await deferredPrompt.userChoice;
deferredPrompt = null;
document.getElementById("install").hidden = true;
});
iOS is different. Safari does not fire beforeinstallprompt; the user installs
through Share → Add to Home Screen. Provide an
apple-touch-icon, and if you want the standalone look, the
apple-mobile-web-app-capable meta tag. Do not build a UI that assumes an
install prompt exists on every platform.
6. Offline, Updates and Testing
An offline-capable app needs an app shell: the HTML, CSS, JavaScript, icons and WebAssembly binary that make the UI start without the network. Cache those at install. Do not cache the user's data in the same cache as the shell; use IndexedDB or the Origin Private File System for data, and treat the cache as a copy of the code.
Test the behaviors, not the source:
- DevTools → Application → Manifest: is it valid and installable?
- Application → Service Workers: is it activated, and which cache is current?
- Network → Offline: does the app still start and render?
- Change a shell file, bump the version, reload: does the update arrive?
- Install on Android, iOS and desktop: does it open standalone?
Run Lighthouse's PWA checks once, but do not treat the score as the goal. The goal is that a user can install the app, open it without a network, and receive an update without clearing site data.
7. This Tutorial Is a PWA
The site you are reading is itself a PWA. Its manifest.json supplies the name,
icons and standalone display; its sw.js pre-caches the pages; its
sw-register.js detects a new worker and asks before reloading. The cache name
carries a version (fast_io-docs-v80.x) that must be bumped whenever the content
changes.
That last detail is the one that bites. A service worker that caches aggressively and a cache version that never changes produce a site that is permanently stale for returning users. The cache is not a deployment detail; it is part of the release.
8. Checklist
- HTTPS, or localhost during development.
- Manifest with name, short name, start URL, scope, display, 192 and 512 icons.
- Service worker registered with the right scope.
- App shell pre-cached; offline start works.
- Cache version bumped on every shell change.
- Update flow tested; no permanent stale state.
- Install tested on Android, iOS and desktop.
- No mixed content; no secrets in the client.