# Ship-Day Cache Checklist Every row is one command or one console line. Run it after a deploy, before you tell anyone the change is live. The class of bug this catches has one signature: **the owner sees the old thing, you see the new thing, and you start to doubt them.** That happens because every habit you have for verifying a deploy samples a client with no prior state, and these bugs only exist for a client with prior state. --- ## 1. immutable on a path that has no fingerprint `immutable` promises the bytes at this URL will never change. It is true for `/js/app.4f2a9c1b.js` and false for `/css/site.css`. A browser that has the false version will not revalidate for the whole `max-age`, commonly a year. ```bash curl -sI https://example.com/css/site.css | grep -i '^cache-control' curl -sI https://example.com/js/site.js | grep -i '^cache-control' ``` FAIL if the value contains `immutable` and the path contains no content hash. Fix: fingerprint the filename, or drop `immutable` and shorten `max-age`. `public, max-age=600, stale-while-revalidate=86400` keeps repeat views instant while allowing the change through. ## 2. The hash is not actually a function of the content A hash stamped from a build timestamp, a version constant, or the clock changes when it should not and stays when it should. Worse than no hash, because it looks solved. ```bash # Determinism: same input, same names npm run build && ls dist/assets > /tmp/a.txt npm run build && ls dist/assets > /tmp/b.txt diff /tmp/a.txt /tmp/b.txt && echo "deterministic" # Sensitivity: one byte changes the name echo "/* x */" >> src/css/site.css npm run build && ls dist/assets > /tmp/c.txt diff /tmp/b.txt /tmp/c.txt && echo "FAIL: content changed, name did not" ``` You need BOTH to pass. Determinism alone is satisfied by a constant. ## 3. A glob header rule stamps immutable on your 404s Header rules match the request path, not the response. A rule for `/*.css` applies to `/nope.css` too, so a 404 gets cached hard, and the URL is poisoned for the life of the `max-age` even after you publish the real file. ```bash curl -sI https://example.com/css/does-not-exist.css | grep -iE '^(http|cache-control)' ``` FAIL if a 404 comes back with a long `max-age` or `immutable`. ## 4. A service worker is answering, so your audit measures the last deploy If the site registers a worker, it responds before the network. Any local audit, any headless check, any "let me just look at it" is now reading whatever the worker cached. ```js // Puppeteer / Playwright: run the audit against the SERVER, not the worker await page.evaluateOnNewDocument(() => { Object.defineProperty(navigator, 'serviceWorker', { get: () => undefined }); }); ``` In one measured case the same audit reported 210 failures with a live worker and 8 without. The 210 were real and they were about the previous deploy. Check whether you have one at all: ```bash curl -s https://example.com/ | grep -oE 'serviceWorker\.register\([^)]*\)' ``` ## 5. Vary: Accept makes every Cache API lookup miss The Cache API matches on headers listed in `Vary` by default. If a response carries `Vary: Accept`, a later `cache.match(request)` will usually miss, because the runtime `Accept` header does not match the one stored. The failure is quiet: the offline page still works, so the worker looks healthy while serving almost nothing from cache. ```js // Every lookup, without exception const hit = await cache.match(request, { ignoreVary: true }); ``` If you do content negotiation (an HTML branch and a Markdown branch on one URL) then `Vary` must be on **both** branches, or a shared cache will hand one audience the other's body. ## 6. A precached URL must byte-match the URL the page requests `cache.addAll` is atomic. One 404 in the list rejects the whole install and you get a worker with an empty cache and no error anywhere obvious. And a precache entry of `/css/site.css` does not serve a page requesting `/css/site.css?v=3`. Query string included, or it misses. ```js // Fail loudly at install rather than silently at first offline load await Promise.all(PRECACHE.map(async (u) => { const r = await fetch(u, { cache: 'no-cache' }); if (!r.ok) throw new Error(`precache 404: ${u}`); })); ``` ## 7. The cache version constant moved backwards A patch that writes a literal version rather than incrementing one can move a site's cache version down. Clients holding a higher version never invalidate. ```bash # Current live version vs what you are about to ship curl -s https://example.com/sw.js | grep -oE "CACHE_(NAME|VERSION)\s*=\s*['\"][^'\"]+" grep -oE "CACHE_(NAME|VERSION)\s*=\s*['\"][^'\"]+" src/sw.js ``` FAIL if the local value sorts lower than the live one. ## 8. One shared cache stamp across a fleet couples every deploy If several sites share a service worker whose cache name is a single shared constant, bumping it to fix one site invalidates the caches of all of them. That is usually not what you wanted, and it makes each site's cache lifetime depend on unrelated work. Prefer a per-site component in the cache name. ## 9. Verify from a WARM client, once, on purpose The whole class exists because nobody does this. Keep one browser profile that you never clear, visit the site before a deploy, deploy, and revisit **without** a hard reload. That is the only check in this list that reproduces what your actual returning visitor experiences. It takes ten seconds and it is the one that would have caught everything above. ## 10. Do not accept "it works for me" as evidence If the owner says the change did not land and it plainly did for you, the disagreement itself is the finding. Ask what they see, and assume the cache until proven otherwise. --- ## The one-line summary `immutable` is a promise about a URL, not about a file. Only make it when the URL changes with the bytes.