How this site works as a PWA¶
This guide is itself a Progressive Web App. You can install it, it works offline for every page you've already read, and it updates itself whenever new content is deployed. This page walks through the real implementation: the manifest, the service worker, the build hook that versions it, and the client-side glue. It's a compact, production case study of the ideas explained throughout the guide.
Key takeaways
- A static documentation site becomes a PWA with four files: a manifest, a service worker, a registration script, and a build step that versions the worker.
- Pages use network-first with a timeout, so readers always get fresh content online and cached content offline.
- Theme assets have content hashes in their file names, so they are safe to serve cache-first. Everything else uses stale-while-revalidate.
- A build hook stamps a content hash into
sw.js. Any content change makes the worker byte-different, which triggers the browser's update check. - The site can call
skipWaiting()because pages are network-first and every fingerprinted asset is also copied into a long-lived cache, so a tab left open across a deploy can still load the files it references.
Architecture at a glance¶
flowchart LR
subgraph Build["mkdocs build"]
MD["Markdown pages"] --> HTML["Static HTML + hashed CSS/JS"]
HTML --> HOOK["hooks/pwa_build.py"]
HOOK -->|"injects BUILD_VERSION + PRECACHE_URLS"| SWJS["sw.js"]
end
subgraph Browser
PAGE["Page + pwa.js"] -->|"register('/sw.js')"| SW["Service worker"]
SW --> PRE[("precache-VERSION")]
SW --> PAGES[("pages-v1")]
SW --> ASSETS[("assets-v1")]
end
SWJS -. deployed .-> SW | File | Role |
|---|---|
docs/manifest.webmanifest | App identity, icons, display mode, shortcuts |
docs/sw.js | Caching strategies, offline fallback, update handling |
docs/assets/javascripts/pwa.js | Registers the worker, shows an install button, lists offline pages |
docs/assets/javascripts/posthog.js | posthog-js 1.194.0, vendored. The last release that speaks this cluster's PostHog |
docs/assets/javascripts/analytics.js | Asks before measuring, then records a pageview on each instant navigation |
nginx/nginx.conf | Proxies /ingest/ to the in-cluster PostHog, and nothing else on that prefix |
hooks/pwa_build.py | Stamps a build version and the precache list into sw.js |
docs/offline.md | The offline fallback page |
overrides/main.html | Manifest link, icons, theme-color and structured data in <head> |
The manifest¶
{
"id": "/",
"name": "Progressive Web Apps — The Complete Technical Guide",
"short_name": "PWA Guide",
"start_url": "/",
"scope": "/",
"display": "standalone",
"display_override": ["standalone", "minimal-ui"],
"background_color": "#ffffff",
"theme_color": "#5a0fc8",
"icons": [
{ "src": "/assets/images/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/assets/images/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/assets/images/icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" },
{ "src": "/assets/images/icons/monochrome-512.png", "sizes": "512x512", "type": "image/png", "purpose": "monochrome" }
],
"shortcuts": [
{ "name": "Service Workers", "url": "/service-workers/" },
{ "name": "Caching Strategies", "url": "/caching/strategies/" }
]
}
A few deliberate choices:
id: "/"pins the app's identity. Ifstart_urlever changes, browsers still treat installs as the same app. See app identity & updates.- Separate
anyandmaskableicons. The maskable versions are full-bleed, with the glyph inside the safe zone. A single icon marked"any maskable"would either be cropped or look padded. See icons & maskable icons. display_overridelistsstandaloneand thenminimal-ui. This lets a browser that supportsminimal-uifall back to it rather than to a browser tab. See display modes.- Shortcuts expose the most visited sections from the launcher icon's context menu. See app shortcuts.
The <head> also carries an apple-touch-icon and a theme-color meta tag for each color scheme, because Safari on iOS reads the Home Screen icon from apple-touch-icon, and <meta name="theme-color" media="..."> can switch with dark mode.
The service worker¶
Caching strategy per request type¶
| Request | Strategy | Why |
|---|---|---|
Pages (navigations and instant-navigation fetch()es) | Network-first, 4 s timeout, then cache, then offline page | Content must be fresh online. Offline readers get the last copy they saw. |
Fingerprinted CSS/JS (*.<hash>.min.js/css) | Cache-first | A given URL never changes content, so the cached copy is always correct. |
| Other same-origin assets (images, fonts, search index) | Stale-while-revalidate | Instant from cache, refreshed in the background. |
/ingest/ (PostHog) | Not intercepted | Same-origin proxy to analytics. Caching it would keep stale config and turn a failed capture into a stored empty response. |
| Cross-origin requests | Not intercepted | Nothing to gain, and opaque responses waste quota. |
The theory behind each strategy is covered in caching strategies.
Install: precache the app shell¶
const BUILD_VERSION = "__BUILD_VERSION__"; // replaced at build time
const PRECACHE_URLS = ["__PRECACHE_URLS__"]; // replaced at build time
const PRECACHE = `precache-${BUILD_VERSION}`;
self.addEventListener("install", (event) => {
event.waitUntil(
(async () => {
const cache = await caches.open(PRECACHE);
// cache: "reload" bypasses the HTTP cache so we never precache a stale copy.
await cache.addAll(PRECACHE_URLS.map((url) => new Request(url, { cache: "reload" })));
// Copy fingerprinted assets into the long-lived assets cache, so tabs
// still running the previous version can load them after a deploy.
const assets = await caches.open(ASSETS);
for (const url of PRECACHE_URLS.filter((u) => HASHED_ASSET.test(u))) {
const response = await cache.match(url);
if (response) await assets.put(url, response);
}
await trimCache(ASSETS, MAX_ASSETS);
await self.skipWaiting();
})()
);
});
The precache holds only the shell: the home page, the offline page, the theme's CSS and JavaScript bundles, the search worker, the logo and the manifest, about 15 files in total. Content pages are cached as you read them, so the first visit doesn't download the whole site. That trade-off is discussed in precaching & runtime caching.
Activate: enable navigation preload and clean up¶
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
if (self.registration.navigationPreload) {
await self.registration.navigationPreload.enable();
}
const keys = await caches.keys();
await Promise.all(
keys
.filter((key) => key.startsWith("precache-") && key !== PRECACHE)
.map((key) => caches.delete(key))
);
await self.clients.claim();
})()
);
});
Navigation preload starts the network request for a page while the service worker is still booting, which removes the worker's startup cost from network-first navigations.
Fetch: route by request type¶
self.addEventListener("fetch", (event) => {
const { request } = event;
if (request.method !== "GET") return;
const url = new URL(request.url);
if (url.origin !== self.location.origin) return;
if (url.pathname === "/sw.js") return;
if (request.mode === "navigate" || isPageRequest(request, url)) {
event.respondWith(handlePage(event));
return;
}
if (HASHED_ASSET.test(url.pathname)) {
event.respondWith(cacheFirst(request, ASSETS));
return;
}
event.respondWith(staleWhileRevalidate(event, ASSETS));
});
respondWith() is always called synchronously, during event dispatch. Requests the worker doesn't want (non-GET, cross-origin) return early without calling it, so the browser handles them normally. See handling fetch events.
Why pages aren't only navigate requests here
Material for MkDocs uses instant navigation: after the first load it fetches the next page with fetch() and swaps the content in without a full reload. These requests have mode: "cors" rather than "navigate", so the worker also treats same-origin requests for text/html or directory-style URLs as page requests. Without this, instant navigations would skip the page cache.
Network-first with a timeout¶
async function handlePage(event) {
const { request } = event;
const cache = await caches.open(PAGES);
// Use the navigation preload response when there is one, otherwise fetch.
const network = (async () => {
const preload = await event.preloadResponse;
const response = preload || (await fetch(request));
if (response.ok) {
const copy = response.clone();
event.waitUntil(
cache.put(stripSearch(request.url), copy).then(() => trimCache(PAGES, MAX_PAGES))
);
}
return response;
})();
try {
return await withTimeout(network, NETWORK_TIMEOUT_MS);
} catch (error) {
// Let a slow response finish in the background so the cache still gets updated.
event.waitUntil(network.catch(() => undefined));
const cached =
(await cache.match(stripSearch(request.url))) ||
(await caches.match(stripSearch(request.url), { ignoreSearch: true }));
if (cached) return cached;
// Instant-navigation fetch()es get a network error, so the theme falls back
// to a real navigation, which then receives the offline page below.
if (request.mode !== "navigate") return Response.error();
const offline = await caches.match(OFFLINE_URL);
return offline || new Response("You are offline.", { status: 503, headers: { "Content-Type": "text/plain" } });
}
}
Several details matter here:
- The timeout protects against "lie-fi", where the device reports a connection but requests hang. After 4 seconds the reader gets the cached copy instead of a spinner.
- The slow request isn't thrown away.
event.waitUntil(network…)keeps the worker alive until it finishes, so the cache still gets the fresh copy for next time. - Cache keys drop the query string and hash, so
?q=searchURLs from the search feature don't create duplicate entries. - The page cache is capped at 150 entries.
trimCache()evicts the oldest entries in insertion order, which keeps storage bounded. See storage quotas.
Versioning: the build hook¶
A service worker updates only when the browser finds that sw.js is byte-different from the installed copy (see updating service workers). A static site's sw.js doesn't change when only the content changes, so a small MkDocs hook rewrites it after every build:
def on_post_build(config, **kwargs):
site_dir = Path(config["site_dir"])
digest = hashlib.sha256()
for path in sorted(p for p in site_dir.rglob("*") if p.is_file()):
rel = path.relative_to(site_dir).as_posix()
if rel in VOLATILE: # sw.js itself and the sitemap
continue
digest.update(rel.encode())
digest.update(path.read_bytes())
version = digest.hexdigest()[:12]
precache = list(STATIC_PRECACHE)
for pattern in SHELL_GLOBS: # hashed theme CSS/JS, search worker, fonts CSS
precache += ["/" + p.relative_to(site_dir).as_posix() for p in sorted(site_dir.glob(pattern))]
source = (site_dir / "sw.js").read_text()
source = source.replace("__BUILD_VERSION__", version)
source = source.replace('["__PRECACHE_URLS__"]', json.dumps(precache))
(site_dir / "sw.js").write_text(source)
The version is a hash of the build output, so it is deterministic. Rebuilding identical content produces an identical worker, which avoids pointless updates for returning visitors. Any content change produces a new worker, a new precache-<version> cache, and cleanup of the previous one in activate.
Why skipWaiting() is safe here¶
By default, a new worker waits until every tab using the old one has closed. Skipping that wait is dangerous when an old page might lazy-load an asset that the new deploy removed. This site avoids the problem in two ways:
- HTML is network-first, so an online reader always gets the current page.
- Theme assets are fingerprinted, and the install handler copies every fingerprinted file into the long-lived
assets-v1cache as well as the versioned precache. The precache is deleted when the next version activates, butassets-v1keeps the old files until they are trimmed (it holds up to 120 entries). An old page that is still open can therefore lazy-load its assets, such as the search worker, after a deploy.
An earlier version of this worker skipped that copy step. Old files then lived only in the versioned precache, which activate deletes, so a tab left open across a deploy could lose its search worker. It's a good example of the pitfall where skipWaiting() breaks lazily loaded assets.
Serving headers¶
The worker script must never be cached for long by the HTTP cache. The _headers file (read by Netlify and Cloudflare Pages) sets:
/sw.js
Cache-Control: no-cache
/manifest.webmanifest
Content-Type: application/manifest+json
/assets/stylesheets/main.*
Cache-Control: public, max-age=31536000, immutable
Browsers cap the HTTP-cache lifetime of service worker scripts at 24 hours for update checks anyway. Still, no-cache makes sure a deploy reaches readers on their next navigation. See HTTP caching & service workers.
Client-side glue¶
pwa.js does three things:
- Registers the worker after the
loadevent, so registration never competes with the page's own resources. It skipslocalhostunless?swis in the URL, somkdocs serve's live reload never fights a cache. - Shows an install button in the header when the browser fires
beforeinstallprompt(Chromium-based browsers). It hides the button afterappinstalled. See install prompts & custom UI. - Lists offline pages on the offline page by reading the keys of the
pages-v1cache.
window.addEventListener("beforeinstallprompt", (event) => {
event.preventDefault(); // suppress the default mini-infobar
deferredPrompt = event; // keep it for our own button
renderInstallButton();
});
Analytics¶
Page views are sent to the PostHog running in the cluster, into the project named PWA. That service is only on the cluster network, so the browser cannot reach it. nginx on this site proxies an allowlist under /ingest/ — capture (/e/, /i/), config (/decide/), lazily loaded SDK files (/static/) and session-replay batches (/s/) — and answers every other path on that prefix with 404. The PostHog UI and its management API are not on the public site.
posthog.js is posthog-js 1.194.0. The cluster runs PostHog release-1.43.0, which serves /decide/?v=3 and /e/. The next SDK release started fetching /array/<token>/config.js, and later releases dropped /decide for /flags/. Neither route exists on 1.43, so the script and the proxy allowlist have to move together.
analytics.js initialises that script with api_host: "/ingest", and only after the visitor presses Allow analytics. Reject is the same kind of button, the bar does not block the page, and Analytics choices in the footer opens it again. The answer is stored in localStorage as pwa-analytics-consent (granted or denied). That key is the memory of the choice, not a visit identifier. The visitor-facing explanation is Analytics and cookies.
The page load is recorded only on the server, by the pageview process beside nginx. nginx mirrors location / to 127.0.0.1, and that process posts to PostHog. The browser does not make that call, so it is not in the network panel. Allow, Reject, DNT and Global Privacy Control are not consulted. The payload is the path, the referrer with the query string removed, the user agent, the IP address, and a new distinct_id. POSTHOG_UPSTREAM and POSTHOG_KEY both have to be set or the process accepts the mirror and stores nothing. Staging leaves them empty. Prefetch requests are not page loads and are not stored. analytics.js sends JavaScript events only after Allow.
The script does not run on localhost or on staging.progressivewebapps.com, and the footer control is removed there. It also does not run when the browser sends DNT: 1 or Global Privacy Control: the bar stays hidden, and any ph_ cookie already stored is deleted. posthog.init writes storage as soon as it is called, and a second init on the same page is ignored, so the first Allow is the only init. Turning analytics off calls opt_out_capturing(). opt_out_persistence_by_default is set so that call deletes the distinct-id cookie rather than only recording a refusal. The script then removes leftover ph_ entries from sessionStorage. A later Allow on the same page calls opt_in_capturing() instead of init.
Material's instant navigation replaces the page without a new document load, so a pageview is captured on document$ (the theme's navigation observable) rather than by the SDK's automatic pageview, which would only see the first one. The bar is appended to document.body once and is not rebuilt on that event. Each event carries environment (production, from the hostname) and display_mode (browser, standalone, fullscreen or minimal-ui). Query strings are removed before the event leaves the browser. save_referrer and store_google are off, because with either one on posthog-js writes location.href, query included, into the measurement cookie. The pageview sets $referrer itself, with the query removed. Autocapture, session recording, surveys and feature flags are off: measurement is page views and page leaves, this PostHog does not serve the extra scripts those features load, and replay is what fills its disk.
The service worker ignores /ingest/. The prefix is same-origin, and /ingest/e/ ends in a slash, so the page strategy would otherwise store those responses in the page cache.
Try it yourself¶
- Install: in Chrome or Edge on desktop, use the install icon in the address bar or the Install button in the header. On iOS, use Share → Add to Home Screen. On macOS Safari, use File → Add to Dock.
- Go offline: open DevTools → Application → Service workers, tick Offline, and navigate to a page you've already visited. Then try one you haven't; you'll get the offline page. See browser DevTools.
- Inspect caches: Application → Cache storage shows
precache-<version>,pages-v1andassets-v1. -
Read the version: in the console, run:
const { port1, port2 } = new MessageChannel(); port1.onmessage = (e) => console.log(e.data); // { version: "…" } navigator.serviceWorker.controller.postMessage({ type: "GET_VERSION" }, [port2]);This uses the
MessageChannelrequest/response pattern from messaging & the Clients API.
Further reading¶
On this site
- Service worker lifecycle
- Caching strategies
- Precaching & runtime caching
- Offline UX & fallbacks
- Updating service workers
- Production checklist
External references