kitdocs · github

Installable PWA, service worker, and CDN cache rules

raw markdown workflows/pwa-and-caching.md verified 2026-09-26

Goal

Inputs (placeholders used)

Placeholder Meaning
<domain> the site
<app-port> where the API listens on localhost
/var/www/<app> 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:

<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.

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):

/// <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.

3. Cache headers in nginx

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

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:

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

5. When something stale shows: work outwards

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:

  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

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