Skip to content

Put a content hash in the asset filenames - #17

Merged
alexhking merged 1 commit into
mainfrom
asset-fingerprinting
Sep 1, 2026
Merged

alexhking merged 1 commit into
mainfrom
asset-fingerprinting

Conversation

@alexhking

@alexhking alexhking commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes the stale-asset window that made the Safari mosaic fix look like it had not shipped.

The problem

/style/main.css and /scripts/main.js had fixed names, so a deploy changed their contents without changing their URLs. The HTML revalidates every request (max-age=0), but the assets come back as max-age=14400, and must-revalidate only applies once an entry is already stale. So for four hours after any deploy, anyone who had visited in the four hours before it gets fresh HTML pointing at stale CSS and JS.

This is what happened after #16. The fix was live and correct, desktop Safari was fine, a private tab was fine, and an iPhone that had loaded the site earlier the same day kept rendering the old build. I diagnosed a WebKit sizing bug twice against code that was no longer running.

The fix

scripts/fingerprint-assets.mjs, wired in as build:fingerprint at the end of npm run build. It renames each built asset to carry a hash of its own bytes and repoints the emitted HTML:

/scripts/build-with-claude.js -> /scripts/build-with-claude.c477cb8b.js
/scripts/link.js              -> /scripts/link.675fa84c.js
/scripts/main.js              -> /scripts/main.cfa66a1f.js
/style/hljs-dracula.css       -> /style/hljs-dracula.ba844b3d.css
/style/main.css               -> /style/main.32b94a7f.css
/umami.js                     -> /umami.4a9683ae.js
fingerprint: hashed 6 assets, rewrote 38 of 38 pages

A URL's contents can now never change, so a stale copy is never the one the page asks for. Six assets, each referenced from exactly one template, nothing off-site linking to them.

Three deliberate choices:

  • Hashes the built output, not the source. A source edit that esbuild optimises away leaves the hash alone, so nobody's cache is busted for a change that did not reach them.
  • Fails the build rather than shipping a broken link. It re-reads every page afterward and exits non-zero if any reference went unrewritten, because a missed one ships as a 404 with no other symptom.
  • Production only. Watch mode rebuilds these continuously and the templates link them unhashed, so npm run dev keeps the plain names. Verified: build:site alone still emits /style/main.css.

It also clears previously-hashed copies before writing, so repeated local builds do not pile up in dist.

Cache headers

_headers now asks for a year and immutable instead of revalidate-every-load, which hashed names make safe, and which caches strictly better than today.

Worth knowing separately: Cloudflare appears to be ignoring Cache-Control from that file. The old rule said /scripts/* max-age=0 and the live response was max-age=14400. That override is most likely a Browser Cache TTL set in the Cloudflare dashboard, which is outside this repo. It does not block this PR, since the hashed names are what actually make staleness impossible and the header only decides how long the correct file is kept, but it is worth a look at the dashboard.

Verification

Check Result
Every asset URL in the HTML resolves to a real file in dist 6/6
Unhashed references remaining none
Hash in each filename matches its actual bytes 6/6
Two consecutive builds, unchanged sources identical filenames, no needless busting
Edited style/main.css new URL, HTML repointed, old file gone
Edited src/index.ts so the output changed new URL, HTML repointed
Stale hashed copies after repeat builds none
Dev mode still emits unhashed names confirmed

Loaded /, /pricing.html, /link.html and /manifesto.html in WebKit and Chromium, at iPhone 14 and 1440x900: no 404s, no JS errors, stylesheet applied, and the mosaic still builds both tracks at 1920px and 10.7 px/sec on mobile, 3078px and 17.1 px/sec on desktop.

The CSS and JS shipped under fixed names, so a deploy changed what
/style/main.css contained without changing its URL. A browser holding the old
copy has no reason to ask for it again, and these come back as max-age=14400,
so for four hours after every deploy a returning visitor gets fresh HTML wired
to stale CSS and JS. That is not an edge case, it is everyone who visited in
the four hours before the deploy.

It cost us a whole debugging round. The Safari mosaic fix went out, the site
was correct in a private tab, and an iPhone that had been on the site earlier
the same day kept showing the old broken build. Two of my diagnoses were of a
bug that was no longer in the code.

build:fingerprint renames each built asset to carry a hash of its own bytes
and repoints the emitted HTML at the new name, so a URL's contents can never
change: a deploy publishes a new name, and the HTML, which revalidates every
load, points at it. Six assets, whose only references are the four templates
that link them. It hashes the built output rather than the source, so a change
that esbuild optimises away does not needlessly bust anyone's cache, and it
fails the build rather than shipping a reference it could not rewrite.

Only in `npm run build`. Watch mode rebuilds these continuously and the
templates link them unhashed, so dev keeps the plain names.

_headers now asks for these to be kept a year rather than revalidated every
load, which hashed names make safe. Worth knowing that Cloudflare has been
ignoring Cache-Control from that file: these came back as max-age=14400 while
the rule said max-age=0, so the override is likely a Browser Cache TTL in the
dashboard. The hashed names are what actually fixes this; the header only
decides how long the correct file is kept.

Claude-Session: https://claude.ai/code/session_013oNA6ETp6gzSGmmpyn3euT
@alexhking
alexhking deployed to archival.dev September 1, 2026 17:11 — with GitHub Actions Active
@alexhking
alexhking merged commit dfcda5a into main Sep 1, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant