CloudFront HTML Caching for the Frontend
The Next.js frontend at www.aiqlick.com / dev.aiqlick.com runs on AWS Amplify, which fronts every response through CloudFront. Without intervention, Next.js 16 ships prerendered HTML with Cache-Control: s-maxage=31536000 — a one-year edge cache. That default is wrong for our deploy model. This page explains why, and how the fix in aiqlick-frontend/next.config.ts works.
TL;DR
- Prerendered HTML routes (
/,/userprofile,/auth/signin, etc.) shipCache-Control: public, s-maxage=60, stale-while-revalidate=300. - Static assets under
/_next/static/...and/_next/image/...keep their existingmax-age=31536000, immutable(filenames are content-hashed, so this is correct). - Deploys propagate to all CloudFront edges within ~60 seconds of Amplify finishing the build.
- A silently-failed CloudFront invalidation cannot pin year-old HTML on users.
Why the Next.js default broke us
Next.js 16 sets Cache-Control: s-maxage=31536000 on every prerendered HTML response. CloudFront treats s-maxage as the edge TTL, so every prerendered page sits in the edge cache for one year unless invalidated.
Two failure modes follow from that:
- Post-deploy propagation race. Even when the invalidation works, there is a 1-5 minute window between "Amplify build finished" and "all CloudFront edges have the new HTML." During that window, a user's HTML response still references the previous build's content-hashed JS chunk filenames, so they run the old code. This is intermittent and the customer-facing symptom is "I just refreshed and the bug is back."
- Silent invalidation failure. Amplify's deploy can succeed while the CloudFront invalidation completes only partially. Without monitoring, that leaves stale HTML on a subset of edges for a year. Concretely, this is the tail risk that produced SUP-00174 — sidebar tab navigation broken because users were running HTML that pre-dated several of our routing fixes.
JS chunk caching is not the problem. Chunks live under /_next/static/ with max-age=31536000, immutable and content-hashed filenames; changing chunks invalidate themselves. The bug is purely in the HTML response that references those chunk filenames.
The fix
aiqlick-frontend/next.config.ts overrides the default for HTML routes only:
async headers() {
return [
{
source: "/:path*",
headers: [
{ key: "Permissions-Policy", value: "microphone=*, camera=*" },
],
},
{
// Excludes _next/static, _next/image, favicon.ico, and any path with a
// file extension — those keep their immutable content-hashed cache.
source: "/((?!_next/static|_next/image|favicon.ico|.*\\..*).*)",
headers: [
{
key: "Cache-Control",
value: "public, s-maxage=60, stale-while-revalidate=300",
},
],
},
];
}
What this gives us:
| Property | Behaviour |
|---|---|
s-maxage=60 | CloudFront serves cached HTML for 60 seconds, then fetches fresh from origin. New deploys are visible within ~60s of Amplify finishing. |
stale-while-revalidate=300 | After the 60s TTL expires, CloudFront returns the stale HTML to the user immediately while it refreshes in the background. So the warm-edge user-perceived latency is unchanged. |
public | CloudFront and any browser-side caches are allowed to store it. |
| Static assets unchanged | /_next/static/..., /_next/image/..., favicon.ico, and anything matching .*\\..* (path with a file extension) keep their existing max-age=31536000, immutable headers. |
Negative-lookahead glossary — the (?!...) pattern in the source field excludes routes from the rule:
_next/static— JS chunks, CSS, fonts._next/image— Image-optimised assets.favicon.ico— The literal favicon path..*\\..*— Any path containing a.(i.e. has a file extension). Catches.png,.svg,.woff2, etc.
Everything else (HTML routes) gets the new shorter cache.
Verification
After a deploy, verify on the affected environment:
# Expect: cache-control: public, s-maxage=60, stale-while-revalidate=300
curl -sI 'https://www.aiqlick.com/userprofile?tab=preference' \
| grep -iE 'cache-control|x-nextjs-cache|age|etag'
# Regression check — static assets MUST stay immutable
CHUNK=$(curl -s 'https://www.aiqlick.com/userprofile?tab=preference' \
| grep -oE '/_next/static/chunks/[a-z0-9_-]+\.js' | head -1)
curl -sI "https://www.aiqlick.com$CHUNK" | grep -i cache-control
# Expect: cache-control: public, max-age=31536000, immutable
Replace www.aiqlick.com with dev.aiqlick.com for the dev environment.
When this matters operationally
Symptoms that point back to this caching layer:
- "I just deployed a fix and refreshed, but the old behaviour is still there." → Wait 60 seconds and refresh again.
- "Some users see the bug, some don't." → Different CloudFront edges, different HTML versions.
- "The fix is in production but a customer screenshot shows the old UI from minutes ago." → Stale edge HTML; hard-refresh or wait for the SWR window to expire.
If a deploy still doesn't seem to land after waiting 5 minutes, the problem is not this cache. Check:
- Amplify build status — did the build actually complete? Check
aws amplify list-jobs --app-id d2x4yh9us5ctu1 --branch-name main. - The HTML response itself — does the chunk filename in the HTML match the new build?
- The chunk filename — is the new chunk reachable at the URL referenced by the HTML?
Why not push it shorter (e.g. 0s, no edge cache)?
Disabling the edge cache entirely would eliminate the stale-HTML problem but cost us:
- Every page request hits Amplify's origin, which is more expensive and slower than serving from the edge.
- During a traffic spike, the origin becomes the bottleneck.
60s is a deliberate trade-off: it bounds the worst-case staleness to "barely noticeable for a real user" while still amortising origin requests for the vast majority of traffic.
Related
- Frontend Deployment — Amplify build pipeline and branch-to-environment mapping.
- Amplify — Amplify app configuration.
- SUP-00174 — Original ticket diagnosing the problem on prod (sidebar tab navigation broken by stale HTML).