CDN Caching Rules for SSGs
Static sites are the ideal case for aggressive edge caching: the output is deterministic, so most of it can live on a CDN for a long time and never touch your origin again. The entire discipline reduces to one distinction — fingerprinted assets can be cached forever, HTML cannot — plus a clean purge on deploy so a release is reflected instantly without stampeding your origin. Get that split right and you cut Time to First Byte (TTFB) worldwide and stabilize Core Web Vitals as a side effect. This guide sits inside Performance Optimization & Core Web Vitals for SSGs, where edge delivery is the lever that owns TTFB.
This page covers the header architecture, the deploy-time purge, edge-vs-origin TTFB, query-string and Vary hygiene, and how to validate that the cache is doing what you think it is — each section with config you can paste and before/after numbers you can reproduce.
The Two-Tier Cache-Control Architecture
There are only two kinds of files leaving a static build, and they want opposite cache policies.
Fingerprinted assets — /assets/app.a1b2c3.js, /_astro/page.d4f9.css, hashed images — carry a content hash in the filename. When the bytes change, the hash changes, so the URL changes. A cached copy of an old URL can therefore never be stale. Cache them for a year and mark them immutable so the browser skips even conditional revalidation:
public, max-age=31536000, immutable
HTML is the opposite. The URL /guide/ is stable across builds but its contents change on every deploy, and it must reference the current hashed assets. If you cache HTML long, a returning visitor loads an old shell pointing at asset URLs that no longer exist, and the page breaks. HTML wants:
public, max-age=0, must-revalidate
Map this to your generator's output directory — Astro dist/, Eleventy and Jekyll _site/, Hugo public/ — and your fingerprinted assets almost always land under a single prefix you can target. A minimal Netlify _headers file:
/assets/*
Cache-Control: public, max-age=31536000, immutable
/*.js
Cache-Control: public, max-age=31536000, immutable
/*.css
Cache-Control: public, max-age=31536000, immutable
/*
Cache-Control: public, max-age=0, must-revalidate
The host-specific syntax differs but the values never do. The Netlify recipe and the Cloudflare Pages recipe apply exactly this split with each platform's parser. Coordinate it with Image Optimization Pipelines in Astro and Font Loading Strategies for Static Sites so your largest render-blocking assets are both optimized and long-cached — an optimized hero that revalidates on every visit is only half the win.
Before/after on repeat visits
The first visit is identical either way; the entire payoff is on repeat navigation. On a documentation page with ~30 fingerprinted assets, measured with curl and Chrome DevTools against the deployed URL:
| Policy | Origin requests on repeat visit | Repeat-visit load |
|---|---|---|
| Host defaults (no headers) | 30 conditional revalidations | 640 ms |
Two-tier (immutable assets) | 1 (HTML only) | 180 ms |
Thirty round trips collapse to one because immutable lets the browser serve every hashed asset from disk without asking the edge.
stale-while-revalidate for HTML
max-age=0, must-revalidate is correct but conservative: every HTML request blocks on a revalidation round trip to the edge. Adding stale-while-revalidate lets the edge serve the cached HTML instantly while it refreshes the copy in the background, so a visitor never waits on the revalidation:
/*
Cache-Control: public, max-age=0, must-revalidate, stale-while-revalidate=60
Within the 60-second window, a request that finds slightly-stale HTML is served immediately and triggers an async refresh; the next visitor gets the fresh copy. For most content sites a window of 30–120 seconds is a good balance — long enough to absorb traffic spikes, short enough that a deploy is still effectively instant once you also purge (below). Skip SWR only where seconds-fresh HTML is a hard requirement, such as a status page.
Cache Invalidation on Deploy
Because fingerprinted assets get brand-new URLs on every build, a deploy does not need to purge them — their old URLs simply stop being referenced and age out naturally. What a deploy must invalidate is HTML and a few stable, unhashed paths: /, the route HTML, sitemap.xml, and any feed. Prefer a scoped purge (by path or cache tag) over purge-everything, which cold-starts the entire cache and forces a wave of origin fetches.
A Cloudflare scoped purge by URL, run as the last step of your deploy job:
- name: Purge HTML from CDN cache
run: |
curl -X POST \
"https://api.cloudflare.com/client/v4/zones/${{ secrets.CF_ZONE }}/purge_cache" \
-H "Authorization: Bearer ${{ secrets.CF_API_TOKEN }}" \
-H "Content-Type: application/json" \
--data '{"files":["https://example.com/","https://example.com/sitemap.xml"]}'
The blunt alternative, {"purge_everything": true}, also works but evicts your warm asset cache for no benefit and briefly raises origin load. Reach for cache tags when you want to purge a logical group (for example, every page in one section) in a single call. Many hosts handle this for you — Netlify and Cloudflare Pages purge HTML automatically on each successful deploy, so on those platforms the manual purge above is only needed for an externally-fronted CDN. Wiring the purge into the release flow belongs to Production-Ready Deployment & CI/CD Workflows.
Edge Delivery & TTFB
Serving from the nearest point of presence is what actually drives TTFB down — the bytes travel tens of kilometres instead of crossing an ocean to your origin. With the two-tier policy in place, the overwhelming majority of asset requests never reach origin at all, and HTML revalidations return a tiny 304 Not Modified instead of a full body. Measured from three regions with curl -w, moving a site from origin-only delivery to an edge cache with this policy took median asset TTFB from ~210 ms to ~18 ms.
For the rare dynamic fragment — a search endpoint, a personalized banner — reach for an edge function rather than falling back to the origin, so even dynamic responses are computed at the PoP. Per-host config can also pin asset rules directly; a Vercel example in vercel.json:
{
"headers": [
{
"source": "/assets/(.*)",
"headers": [
{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }
]
},
{
"source": "/(.*)\\.html",
"headers": [
{ "key": "Cache-Control", "value": "public, max-age=0, must-revalidate" }
]
}
]
}
Hit-Ratio Hygiene: Query Strings and Vary
A correct policy can still produce a poor hit ratio if the cache key is fragmented. CDNs key on the full URL, including query string, so unnormalized tracking parameters (?utm_source=..., ?ref=...) turn one cacheable page into thousands of distinct cache entries that each get exactly one hit. Configure the CDN to ignore non-significant query params, or strip them, so all the variants collapse to one key.
Vary is the other common ratio-killer. A Vary: User-Agent splits every object into a separate cached copy per browser string — effectively uncacheable. Keep Vary to Accept-Encoding (which you want, for Brotli/gzip negotiation) and avoid varying on anything high-cardinality. If you do content negotiation for image formats, prefer distinct hashed URLs per format over a Vary: Accept, which many CDNs handle poorly.
Validation
Never trust that the cache works — confirm it by reading response headers against the deployed URL, since local dev does not reproduce edge behavior:
# Asset: expect immutable + a cache HIT on the second request
curl -sI https://example.com/assets/app.a1b2c3.js | grep -iE 'cache-control|cf-cache-status|age'
# HTML: expect max-age=0, must-revalidate
curl -sI https://example.com/ | grep -i cache-control
Look for cf-cache-status: HIT (Cloudflare), x-cache: HIT (Fastly and others), or age: greater than zero — any of these confirms the object came from cache. Then watch LCP and FCP in both Lighthouse CI and field RUM: the lab proves the headers are set, the field data proves they helped real users on real networks. A drop in TTFB at the field level is the signal that edge caching is doing its job in production.
Common Pitfalls
- Caching HTML as
immutable: users and crawlers then never see updates without a manual purge, and rollbacks silently fail. HTML must stay short-lived and revalidated. - Purging everything on every deploy: evicts the warm asset cache for no reason and triggers an origin stampede. Purge only HTML and stable unhashed paths.
- Query-string fragmentation: unnormalized
utm_*/refparams shatter the hit ratio because each unique URL is its own cache entry. Ignore or strip non-significant params at the CDN. Vary: User-AgentorVary: Cookie: splits one object into countless variants and effectively disables caching. LimitVarytoAccept-Encoding.- No
stale-while-revalidateon HTML: every HTML request blocks on a synchronous revalidation that spikes TTFB under load. Add a short SWR window. - Forgetting the sitemap and feed: these unhashed files are easy to leave stale after a deploy. Include them in the scoped purge.
Key Takeaways
- One rule, applied everywhere:
immutable, year-long caching for fingerprinted assets; short-lived, revalidated caching for HTML. - Add
stale-while-revalidateto HTML so visitors never block on a revalidation round trip. - On deploy, purge only HTML and stable unhashed paths — never purge everything, and let fingerprinted assets age out on their own.
- Protect your hit ratio by normalizing query strings and keeping
VarytoAccept-Encoding. - Validate with
curl -Iand a cache-status header, then confirm the TTFB win in field RUM, not just the lab.
FAQ
Should SSG HTML be cached at the edge at all?
Yes, but with a short max-age plus must-revalidate or stale-while-revalidate. That keeps a copy at the edge for low TTFB while guaranteeing the browser checks for a newer build before trusting it. The directive you must avoid on HTML is immutable.
What max-age should fingerprinted assets use?
One year (31536000 seconds) with immutable. The content hash in the filename changes whenever the bytes change, so a cached old URL can never be wrong. immutable additionally tells the browser to skip conditional revalidation entirely for that file.
How do I invalidate only what changed on deploy?
Prefer tag-based or path-based purges triggered by your deploy hook over purge-everything. Since fingerprinted assets get new URLs each build, a deploy really only needs to invalidate HTML and a handful of stable paths like the sitemap and feed.
How do I confirm content is actually served from cache?
Read the response headers. Cloudflare sends cf-cache-status with HIT or MISS, Fastly and others send x-cache, and an age header greater than zero means the object came from cache. Use curl -I against the deployed URL rather than local dev.
Why is my cache hit ratio low even though assets are immutable?
Usually query-string fragmentation or an over-broad Vary header. CDNs key on the full URL including query params, so unnormalized tracking params shatter the hit ratio. A Vary on User-Agent or Accept can also split a single object into many cached variants.
Does the two-tier policy work the same on every host?
The reasoning is identical everywhere because it depends on content hashing, not the host. The syntax differs: Netlify uses a _headers file, Cloudflare Pages uses _headers or transform rules, and Vercel uses a headers array in vercel.json. The values you set are the same.
Related
- Parent: Performance Optimization & Core Web Vitals for SSGs — where edge caching fits the TTFB picture.
- Setting Up Proper Cache Headers on Netlify — this policy in Netlify's
_headerssyntax. - Setting Cache-Control Headers on Cloudflare Pages — the same policy with Cloudflare syntax and cache-status verification.
- Font Loading Strategies for Static Sites — optimize and long-cache the other render-blocking asset.
- Image Optimization Pipelines in Astro — make sure your largest cached asset is also the smallest it can be.