index.html,
sw.js and the manifest never.| Placeholder | Meaning |
|---|---|
<domain> |
the site |
<app-port> |
where the API listens on localhost |
/var/www/<app> |
built static files |
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.
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:
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png">
<meta name="theme-color" content="#…">
Check: Chrome DevTools → Application → Manifest shows no errors; "Add to Home Screen" on an iPhone shows your icon, not a page screenshot.
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):
/// <reference lib="webworker" />
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.
location /api/ {
proxy_pass http://127.0.0.1:<app-port>;
# (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:
for p in / /index.html /sw.js /manifest.webmanifest; do
printf "%-24s " "$p"; curl -sI "https://<domain>$p" | grep -i '^cache-control' || echo MISSING
done
curl -sI "https://<domain>/assets/$(curl -s https://<domain>/ | grep -o 'index-[^"]*\.js' | head -1)" | grep -i cache-control
Cache-Control: no-storeCloudflare 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:
app.addHook("onSend", async (_req, reply) => { reply.header("cache-control", "no-store"); });
Check, from outside with no cookies:
curl -sI https://<domain>/api/<any-endpoint-with-an-extension> | grep -iE 'cache-control|cf-cache-status'
# cache-control: no-store ; cf-cache-status: BYPASS or DYNAMIC — never HIT
Service worker → browser → CDN → origin.
curl -sI https://<domain>/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 <domain>:443:<origin-ip> https://<domain>/icon.png # the origin, bypassing the CDN
Fix both halves:
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.
og.jpg for link previews) gets a ?v=<date> in
the meta tag, bumped whenever it changes./hero/v2/) and cache the folder long.| 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 |
apple-touch-icon/assets immutable, index.html/sw.js/manifest no-cache, icons shortCache-Control: no-store, with a test, verified with cookie-less curl through the CDN