Baobaobaolin.com
date
entry
019
topic
infrastructure
rev

A static site needs three cache policies, not one

One max-age across the whole site has two endings: set it long and your changes do not appear; set it short and every request reaches origin, so the CDN bought you nothing. The test that decides is a single sentence.

Does this file's name change when its contents change?

Files where the answer is yes can be cached forever — different content means a different URL, and nobody will ever ask for the old one again. Files where the answer is no must be revalidated every time, because the same URL may hold something else tomorrow.

Drawing that line through a static site produces exactly three groups:

GroupName changes?Cache-Control
Hashed assets (JS/CSS/images)yespublic, max-age=31536000, immutable
HTMLnopublic, max-age=0, must-revalidate
Feeds and sitemapnopublic, max-age=3600

Three syncs

aws s3 sync applies --cache-control to the whole batch, so it runs three times with --exclude and --include splitting the files:

# 1. hashed assets: a year, never revalidated
aws s3 sync dist/ "s3://$BUCKET/" --delete \
  --exclude "*.html" --exclude "*.xml" \
  --cache-control "public, max-age=31536000, immutable"

# 2. HTML: cacheable, but ask every time
aws s3 sync dist/ "s3://$BUCKET/" --delete \
  --exclude "*" --include "*.html" \
  --cache-control "public, max-age=0, must-revalidate" \
  --content-type "text/html; charset=utf-8"

# 3. feeds and sitemap: an hour
aws s3 sync dist/ "s3://$BUCKET/" \
  --exclude "*" --include "*.xml" \
  --cache-control "public, max-age=3600"

The three filters are mutually exclusive, so each file is handled by exactly one pass and none overwrites another. Order does not matter.

What immutable adds to a long max-age

A year is already a long max-age, so what does immutable do? The difference shows up when someone hits reload.

Without it, a reload makes the browser revalidate every resource even though none has expired. Those requests mostly come back 304 with no body — but you still pay the round trip. immutable states plainly that this URL's content will never change, so there is nothing to ask about.

This depends on the filenames genuinely being hashed. Put immutable on an app.js whose name is fixed and whose content rotates, and you have locked users onto an old build for a year with no server-side way to release them — only a URL change works.

max-age=0 does not mean "do not cache"

The most misread directive. max-age=0, must-revalidate means "store it, but check freshness before each use". It does not mean "you may not store it".

With an ETag in place, that check usually returns 304 Not Modified: no HTML transferred, just one round trip. On a 14 KB page that saves nearly all of the bytes.

The directive that genuinely forbids storage is no-store, and it exists for dynamic pages carrying personal data. Static HTML does not need it.

Do not drop the --content-type "text/html; charset=utf-8" in the second pass. S3 can infer that .html is HTML, but it will not add a charset, and the HTTP header outranks the <meta charset> inside the document. On a Chinese site, omitting it means mojibake under some browser configurations.

Why the feed gets an hour

Feeds and sitemaps have fixed names, so by the rule above they should be revalidated like HTML. I give them an hour because readers do not need second-level freshness — RSS clients poll every few tens of minutes anyway, and search engines fetch a sitemap far less often.

The cost is that a new post can take up to an hour to reach subscribers. Acceptable for a blog; unacceptable for a news site. Cache duration should follow "how late is too late", not the file extension.

Invalidation is not a cache policy

CloudFront invalidation is useful, but it is an escape hatch rather than part of the design. It costs money past the free tier, it costs time (tens of seconds to minutes), and it clears the CDN, not the copy in a user's browser.

That last point is the one that matters. If HTML is cached for a day, invalidating the CDN does nothing for someone who visited yesterday — they still see the old page today, and you cannot reproduce it in your own private window.

So the max-age=0 on HTML is the actual mechanism; invalidation only makes the CDN layer catch up immediately.

And because invalidation is not instant, a deploy script has to wait before comparing live content — verify too early and you read edge nodes that have not turned over yet, producing false failures.

If you remember one thing

Cache policy is determined by the nature of the filename, not by how quickly you would like things to update. Hashed name, cache forever. Fixed name, revalidate every time. The intuitive middle values — "ten minutes is probably fine" — usually collect the drawbacks of both.

Revision history

  1. First published