Skip to content

fix(frontend): send Cache-Control on deploy so a new build is actually served - #1197

Merged
philmerrell merged 1 commit into
developfrom
fix/spa-index-cache-control
Sep 19, 2026
Merged

philmerrell merged 1 commit into
developfrom
fix/spa-index-cache-control

Conversation

@philmerrell

Copy link
Copy Markdown
Contributor

The bug

scripts/frontend/deploy.sh synced the SPA to S3 with no Cache-Control metadata on any object, so CloudFront serves index.html with no Cache-Control at all:

$ curl -sI https://dev.boisestate.ai/
last-modified: Sat, 19 Sep 2026 20:38:31 GMT
etag: "ceafb2810d6627aa1a72e0fa2012cd75"
(no cache-control)

With no explicit freshness directive, browsers fall back to heuristic caching — roughly 10% of the document's age at the time it was cached. A shell that had been in the bucket for days is therefore reusable for hours, with no revalidation.

Content-hashed bundle names do not save you. The stale shell names the old hashes, those objects are still in the bucket, and every request succeeds. The failure mode is not an error — it is a deploy that appears to have silently not happened. That is exactly how this was found: the orb work in #1188 was live at the origin while the browser kept rendering the previous build.

The /* CloudFront invalidation the deploy already runs does not help, because the stale copy is in the browser, not at the edge.

The fix

The sync becomes two passes:

files header
main-ZMUT4FS2.js, chunk-4DOJ3VBG.js, styles-DIRZRL6Y.css … public, max-age=31536000, immutable
index.html, favicons, fonts, site.webmanifest, worklets no-cache

no-cache means "cache it, but check first" — the revalidation is a 304 on a 32KB file, not a re-download.

Why the pattern matches a hash, not an extension

public/ is copied verbatim and is not hashed, and it contains audio/pcm-capture.worklet.js. A rule keyed on *.js would have pinned that worklet, unversioned, for a year — the one mistake here that is genuinely painful to undo. So the pattern is *-[A-Z0-9]{8}.js|css, and anything it fails to match falls through to the revalidate pass. The failure direction is a slower asset, never a stuck one.

Verified by matching the patterns against all 66 unhashed files in public/ plus the five bundle names currently live on dev:

MUST be pinned:      5/5 matched
MUST NOT be pinned:  0 leaks across 66 files
RESULT: PASS

Pass order is load-bearing

Pass 2 has no excludes, so it sweeps the whole tree — which is what keeps --delete honest, since AWS CLI filters apply to the delete evaluation too and excluding the bundles there would exempt stale ones from the prune. It relies on aws s3 sync skipping files whose size matches and whose local mtime is not newer, so the bundles pass 1 just uploaded keep their immutable header. Reversed, every bundle would be stamped no-cache. This is commented in the script.

What I could not verify locally

The skip-on-second-pass behaviour needs a real S3 round trip, so it is proven only by the first deploy this merges into. The failure direction is safe — if sync re-uploaded the bundles, they would get no-cache and revalidate, a perf regression rather than a correctness bug. I will curl dev after the deploy and confirm index.html carries no-cache and a hashed chunk carries immutable.

Note, not changed here

FrontendCachePolicy sets minTtl: 1 minute, which overrides origin no-cache for CloudFront's own cache — so an edge may serve an index up to 60s stale. It does not affect what the browser is told, since CloudFront still forwards the header downstream, and the deploy's invalidation flushes the edge anyway. Left alone to keep this to the deploy script; worth revisiting if 60s ever matters.

This script also runs for main, so prod picks the fix up on the next release.

🤖 Generated with Claude Code

…y served

The SPA was synced to S3 with no Cache-Control metadata on any object,
so CloudFront served index.html with no Cache-Control at all. With no
explicit freshness directive a browser falls back to HEURISTIC caching —
roughly 10% of the document's age when it was cached — which for a shell
that had been sitting in the bucket for days means hours of reuse with no
revalidation.

Hashed bundle names do not save you here. The stale shell names the OLD
hashes, those objects still exist, and everything loads cleanly. The
failure mode is not an error: it is a deploy that appears to have silently
not happened, which is exactly how it was found — the orb work in #1188
was live at the origin while the browser kept rendering the previous
build. The CloudFront invalidation the deploy already runs does not help,
because the stale copy is in the browser, not at the edge.

The sync is now two passes:

- Hashed bundles (main-ZMUT4FS2.js, chunk-4DOJ3VBG.js, styles-*.css) get
  `public, max-age=31536000, immutable`. Safe because the name is a
  function of the content.
- Everything else, index.html above all, gets `no-cache` — cache it, but
  revalidate. That costs a 304 on a 32KB file.

The pattern matches an 8-character hash rather than a `.js` suffix, on
purpose: `public/` is copied verbatim and unhashed, and it holds
`audio/pcm-capture.worklet.js`, which an extension rule would have pinned
for a year. Verified against all 66 unhashed files in `public/` plus the
five bundle names live on dev: 5 pinned, 0 leaked.

Pass order is load-bearing and commented as such — the second pass sweeps
the whole tree so --delete keeps pruning stale bundles, and relies on sync
skipping the files the first pass just uploaded so they keep the immutable
header.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@philmerrell
philmerrell merged commit 7232975 into develop Sep 19, 2026
6 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