Skip to main content

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.) ship Cache-Control: public, s-maxage=60, stale-while-revalidate=300.
  • Static assets under /_next/static/... and /_next/image/... keep their existing max-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:

  1. 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."
  2. 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:

PropertyBehaviour
s-maxage=60CloudFront serves cached HTML for 60 seconds, then fetches fresh from origin. New deploys are visible within ~60s of Amplify finishing.
stale-while-revalidate=300After 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.
publicCloudFront 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:

  1. Amplify build status — did the build actually complete? Check aws amplify list-jobs --app-id d2x4yh9us5ctu1 --branch-name main.
  2. The HTML response itself — does the chunk filename in the HTML match the new build?
  3. 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.

  • 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).