fix(frontend): send Cache-Control on deploy so a new build is actually served - #1197
Merged
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
scripts/frontend/deploy.shsynced the SPA to S3 with noCache-Controlmetadata on any object, so CloudFront servesindex.htmlwith noCache-Controlat all: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:
main-ZMUT4FS2.js,chunk-4DOJ3VBG.js,styles-DIRZRL6Y.css…public, max-age=31536000, immutableindex.html, favicons, fonts,site.webmanifest, workletsno-cacheno-cachemeans "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 containsaudio/pcm-capture.worklet.js. A rule keyed on*.jswould 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:Pass order is load-bearing
Pass 2 has no excludes, so it sweeps the whole tree — which is what keeps
--deletehonest, since AWS CLI filters apply to the delete evaluation too and excluding the bundles there would exempt stale ones from the prune. It relies onaws s3 syncskipping 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 stampedno-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-cacheand revalidate, a perf regression rather than a correctness bug. I will curl dev after the deploy and confirmindex.htmlcarriesno-cacheand a hashed chunk carriesimmutable.Note, not changed here
FrontendCachePolicysetsminTtl: 1 minute, which overrides originno-cachefor 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