--- title: Installable PWA, service worker, and CDN cache rules summary: Make an app installable and offline-tolerant without ever hiding a deploy, and set cache headers so neither the browser, the service worker nor Cloudflare serves something stale — or something private. when_to_use: Adding "install to home screen" to a web app; a deploy "didn't show up"; an old image/icon keeps loading; or before putting any authenticated API behind Cloudflare's proxy. prerequisites: A Vite (or similar) build with hashed asset names, nginx in front of the app, optionally Cloudflare proxying the domain. Service worker examples use vite-plugin-pwa + Workbox. related: [workflows/browser-flow-testing.md] last_verified: 2026-09-26 --- # Installable PWA, service worker, and CDN cache rules ## Goal - The app installs on iOS and Android with a proper icon. - A deploy is visible on the **next** load, not the one after. - Hashed assets are cached forever; fixed-name files briefly; `index.html`, `sw.js` and the manifest never. - **No API response is ever stored** by a browser, a service worker or a CDN. ## Inputs (placeholders used) | Placeholder | Meaning | |---|---| | `` | the site | | `` | where the API listens on localhost | | `/var/www/` | built static files | ## The one rule **What changes comes from the network; what never changes comes from the cache.** Every step below applies it to one layer: service worker, nginx headers, CDN. ## Steps ### 1. Manifest and icons With vite-plugin-pwa (or by hand), ship a `manifest.webmanifest` with `name`, `short_name`, `start_url: "/"`, `scope: "/"`, `display: "standalone"`, `theme_color`, `background_color`, and icons 192, 512 and a 512 **maskable**. Generate them from one SVG (`npx pwa-assets-generator`). **iOS ignores manifest icons.** Add to `index.html`: ```html ``` **Check:** Chrome DevTools → Application → Manifest shows no errors; "Add to Home Screen" on an iPhone shows your icon, not a page screenshot. ### 2. A service worker that is network-first for pages The classic `caches.match(req).then(hit => hit || fetch(req))` applied to everything serves the old `index.html` to returning visitors, so every deploy appears one load late. `skipWaiting()`/`clients.claim()` don't fix it — the page already rendered from cache. Split by request type (Workbox, `injectManifest` strategy): ```ts /// import { clientsClaim } from "workbox-core"; import { cleanupOutdatedCaches, matchPrecache, precacheAndRoute } from "workbox-precaching"; import { NavigationRoute, registerRoute } from "workbox-routing"; self.skipWaiting(); clientsClaim(); cleanupOutdatedCaches(); // Hashed assets: precached, served from cache. Empty directoryIndex stops "/" // from being answered cache-first. precacheAndRoute(self.__WB_MANIFEST, { directoryIndex: "" }); // Pages: network first; the cached shell only when offline. Never the API. registerRoute(new NavigationRoute(async ({ request }) => { try { return await fetch(request); } catch { return (await matchPrecache("/index.html")) ?? Response.error(); } }, { denylist: [/^\/api\//] })); ``` Large optional assets (an OCR engine, a model file) should **not** be precached — exclude them from the glob and cache them at runtime (`CacheFirst` + `ExpirationPlugin`) under a **versioned path**, so a new version is a new URL. Don't auto-reload the page when a new worker activates if users type data that isn't saved yet; the next navigation picks up the new build anyway. **Check:** deploy a visible change, reload once on a device that had the old version — the change is there. ### 3. Cache headers in nginx ```nginx location /api/ { proxy_pass http://127.0.0.1:; # (the API itself sends Cache-Control: no-store — step 4) } # Hashed filenames: bytes never change under the same name. location /assets/ { add_header Cache-Control "public, max-age=31536000, immutable"; include snippets/security-headers.conf; try_files $uri =404; } # Must never be cached, or installed phones never update. location = /sw.js { add_header Cache-Control "no-cache, no-store, must-revalidate"; include snippets/security-headers.conf; } location = /manifest.webmanifest { add_header Cache-Control "no-cache"; include snippets/security-headers.conf; } location = /index.html { add_header Cache-Control "no-cache"; include snippets/security-headers.conf; } # Fixed names that can change between deploys (icons): short and revalidated. location ~* \.(png|ico|svg)$ { add_header Cache-Control "public, max-age=3600, must-revalidate"; include snippets/security-headers.conf; try_files $uri =404; } location / { try_files $uri $uri/ /index.html; } ``` **`add_header` in a `location` discards every `add_header` inherited from `server`** — that is why each location repeats the security-headers include. **Check:** ```bash for p in / /index.html /sw.js /manifest.webmanifest; do printf "%-24s " "$p"; curl -sI "https://$p" | grep -i '^cache-control' || echo MISSING done curl -sI "https:///assets/$(curl -s https:/// | grep -o 'index-[^"]*\.js' | head -1)" | grep -i cache-control ``` ### 4. Every API response: `Cache-Control: no-store` Cloudflare caches by **file extension**, regardless of path. `.css .js .png .pdf .csv` and many more are on its default list. In one app, `/api/export.csv` returned the signed-in user's data with no cache header; Cloudflare cached the first response and would have served it to anyone, logged in or not, for hours. The origin was correct on every request — the leak was entirely in the CDN. Fix it once for the whole API (Fastify example) and test it: ```ts app.addHook("onSend", async (_req, reply) => { reply.header("cache-control", "no-store"); }); ``` **Check, from outside with no cookies:** ```bash curl -sI https:///api/ | grep -iE 'cache-control|cf-cache-status' # cache-control: no-store ; cf-cache-status: BYPASS or DYNAMIC — never HIT ``` ### 5. When something stale shows: work outwards Service worker → browser → CDN → origin. ```bash curl -sI https:///icon.png | grep -iE 'content-length|cf-cache-status|age' # HIT + the old size = the CDN has it. The origin is fine. curl -sI -k --resolve :443: https:///icon.png # the origin, bypassing the CDN ``` Fix both halves: 1. **Purge** the URL (Cloudflare dashboard → Caching → Configuration → Custom Purge; the free plan supports URL and prefix purges there). 2. **Stop causing it:** content-hash the filename (import the image through the bundler rather than putting it in `public/`), or give fixed names a short max-age. Cloudflare's **Browser Cache TTL** setting can rewrite the `max-age` browsers receive, so public and origin responses can disagree — trust the origin response for what nginx sends. ### 6. Updating screenshots and images of the app - **Capture through a dead service worker.** A headless browser with an old profile gets the previous build from the SW. Use a fresh profile, or unregister SWs and reload before the capture. - Images that change should get **new URLs** (hashed by the bundler). A file that must keep a fixed URL (e.g. `og.jpg` for link previews) gets a `?v=` in the meta tag, bumped whenever it changes. - Large frame sequences (scroll animations): put a version in the folder name (`/hero/v2/`) and cache the folder long. ## Traps | Symptom | Cause | Fix | |---|---|---| | Deploy appears on the load after next | SW cache-first on navigations | network-first `NavigationRoute` (step 2) | | Installed phones never update | `sw.js` cached by browser or CDN | `no-cache, no-store` on `/sw.js` | | Old icon/logo after deploy | CDN edge cached a fixed filename | purge + short max-age or hashed name | | Security headers missing on some paths only | a location with its own `add_header` dropped the inherited ones | repeat the include in every such location | | User A's export served to user B / to anyone | CDN cached an API URL with a file extension | `no-store` on every API response + test | | iPhone home-screen icon is a page screenshot | iOS ignores manifest icons | `apple-touch-icon` link | | Screenshot of the app shows the old design | captured via a live SW | fresh profile / unregister SW | | "Still broken" right after a deploy | the user's tab holds the old build | reload twice, or check in a fresh profile before debugging | ## Done when - [ ] Manifest valid; icons including maskable and `apple-touch-icon` - [ ] SW: precache hashed assets, network-first navigations, API excluded - [ ] Headers verified per path: `/assets` immutable, `index.html`/`sw.js`/manifest no-cache, icons short - [ ] Every API response `Cache-Control: no-store`, with a test, verified with cookie-less `curl` through the CDN - [ ] A deploy shows on the next reload of an installed app