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, cheap revalidation with ETag and 304s, the deploy-time purge, tiered caching and origin shielding, 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. TTFB is the metric edge caching moves directly; the paint metrics it improves downstream, Largest Contentful Paint especially, are covered in their own guides.

Edge cache decision flow for a static site request A request arrives at the edge. Fingerprinted assets are served from an immutable one-year cache as a HIT; HTML is checked against the origin with must-revalidate, returning a fast 304 or a fresh body; a deploy purges only HTML and stable paths, leaving asset cache warm. One request, two cache lifetimes, a clean purge on deploy Request hits edge nearest PoP Hashed asset? yes Serve from edge — HIT max-age=31536000, immutable never revalidated · ~5 ms no — HTML Revalidate with origin max-age=0, must-revalidate 304 Not Modified → fast or 200 fresh body Deploy → scoped purge purge HTML + sitemap/feed asset cache stays warm no origin stampede A new build mints new asset URLs, so caching the old ones forever is always safe.
Hashed assets are served straight from the edge as immutable HITs; HTML always revalidates so it points at the current assets; a deploy purges only HTML and stable paths, leaving the asset cache warm.

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:

PolicyOrigin requests on repeat visitRepeat-visit load
Host defaults (no headers)30 conditional revalidations640 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.

Pair the window with stale-if-error so the edge keeps serving the last good HTML when a deploy fails or the origin returns a 5xx, instead of propagating the error to visitors:

/*
  Cache-Control: public, max-age=0, must-revalidate, stale-while-revalidate=60, stale-if-error=86400

Cheap Revalidation: ETag, Last-Modified and 304s

must-revalidate on HTML means the browser and edge do check the origin — but a revalidation is not a full download. Every static generator writes a strong validator alongside each file: an ETag (a hash of the body) and a Last-Modified timestamp. On revalidation the client sends If-None-Match: "<etag>"; if the build hasn't changed that file, the origin answers 304 Not Modified with empty body, and the client reuses its cached copy. A 304 is a few hundred bytes over an already-warm connection, so the "cost" of revalidating unchanged HTML is a header exchange, not a page download.

The practical failure mode is a generator or host that emits a weak or per-request ETag — one that changes even when the bytes don't (some hosts derive it from a timestamp or a gzip stream). That guarantees a 200 full body on every revalidation and quietly erases the benefit. Confirm the validator is stable across two requests:

# The ETag must be byte-identical on repeated requests to an unchanged file
curl -sI https://example.com/ | grep -i etag
curl -sI https://example.com/ | grep -i etag

# Force a conditional request and expect: HTTP/2 304
curl -sI -H 'If-None-Match: "PASTE-ETAG-HERE"' https://example.com/

On a mid-sized documentation home page, switching a host from a volatile ETag (full 200, 42 KB) to a stable one (304, 0.3 KB) took the revalidation transfer from 42 KB to under a kilobyte and shaved ~70 ms off repeat-visit TTFB on a slow connection — with no change to freshness, since the body only ships when it actually changed.

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. Keeping those fragments small and deferred is the same discipline as JavaScript Hydration & Partial Rendering: cache the static shell aggressively, and treat the interactive slice as the narrow exception. 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" }
      ]
    }
  ]
}

Tiered Caching and Origin Shield

A single-layer CDN caches independently at every point of presence, so a globally distributed audience produces one origin MISS per PoP for each cold object — a build with hundreds of edge locations can hammer your origin with hundreds of near-simultaneous fetches right after a purge. Tiered caching inserts a regional (or single "shield") layer between the edge PoPs and origin: an edge MISS first checks a nearby upper tier, and only a true upper-tier MISS reaches origin. One object is then fetched from origin once and fanned out to every PoP.

Flat single-tier caching versus a tiered origin shield Two topologies for resolving a cold-object MISS. In the flat single-tier case, four edge points of presence each fetch the same object directly from the static origin, producing four simultaneous origin fetches — a stampede. In the tiered case, the four points of presence fetch from a regional shield tier, which makes exactly one fetch to origin and fans the result back out. Two topologies for resolving one cold-object MISS after a purge Single-tier (flat) Tiered / origin shield PoP PoP PoP PoP Static origin object store 4 simultaneous origin fetches — stampede PoP PoP PoP PoP Regional shield upper cache tier 1 fetch Static origin object store one origin fetch, fanned back out to every PoP
A flat CDN resolves a cold object with one origin fetch per point of presence, so a purge fans out into a stampede; a regional shield tier collapses that to a single origin fetch that is redistributed to every PoP.

For static sites this matters most in the seconds after a deploy purge, when otherwise every PoP re-fetches HTML at once. Cloudflare enables Argo/Smart Tiered Caching or a fixed regional topology via API or dashboard; Fastly's shielding designates one POP as the origin shield per service. The values you already set still apply — tiering changes where a MISS is resolved, not the Cache-Control on the object.

TopologyOrigin fetches for one cold HTML objectOrigin load after a global purge
Single-tier (flat)one per edge PoPspikes with audience spread
Tiered / origin shieldone, fanned out to all PoPsflat and predictable

Because a static origin is usually just object storage or a build artifact server, the win is less about origin CPU and more about a predictable, un-stampeded refresh — which is exactly what lets you keep the deploy purge scoped and fast rather than rate-limited. This dovetails with how releases are sequenced in Production-Ready Deployment & CI/CD Workflows.

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_*/ref params shatter the hit ratio because each unique URL is its own cache entry. Ignore or strip non-significant params at the CDN.
  • Vary: User-Agent or Vary: Cookie: splits one object into countless variants and effectively disables caching. Limit Vary to Accept-Encoding.
  • No stale-while-revalidate on HTML: every HTML request blocks on a synchronous revalidation that spikes TTFB under load. Add a short SWR window.
  • An unstable ETag on HTML: a validator that changes per request forces a full 200 on every revalidation, silently defeating must-revalidate. Verify the ETag is byte-identical across two requests to an unchanged file.
  • Forgetting the sitemap and feed: these unhashed files are easy to leave stale after a deploy. Include them in the scoped purge.
The two-tier policy in one picture Two tiers. Hashed assets get one year of immutable caching because their filename changes when their content does. HTML gets zero max-age with revalidation, so readers always get the current page for the cost of a cheap 304 response. The two-tier policy in one picture Hashed assets — max-age=31536000, immutable filename changes with content, so the cache never needs invalidating HTML — max-age=0, must-revalidate always current; a 304 costs a round trip and no bytes Everything else — decide explicitly unhashed images, feeds and downloads need a stated policy Set both tiers before optimising anything else — most CDN tuning is downstream of this one decision.
Almost every caching problem on a static site is a file in the third band that nobody assigned to one of the first two.

Key Takeaways

  • One rule, applied everywhere: immutable, year-long caching for fingerprinted assets; short-lived, revalidated caching for HTML.
  • Add stale-while-revalidate (and stale-if-error) to HTML so visitors never block on a revalidation round trip or see a failed deploy.
  • Keep the ETag stable so unchanged HTML revalidates as a tiny 304 instead of a full 200 body.
  • On deploy, purge only HTML and stable unhashed paths — never purge everything, and let fingerprinted assets age out on their own.
  • Add tiered caching or an origin shield so a purge triggers one origin fetch, not one per point of presence.
  • Protect your hit ratio by normalizing query strings and keeping Vary to Accept-Encoding.
  • Validate with curl -I and 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. Keep Vary to Accept-Encoding only.

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.

If HTML must revalidate, why isn't revalidation slow?

Because a revalidation is a header exchange, not a download. The client sends the stored ETag as If-None-Match; if the build hasn't changed that file the origin returns a 304 Not Modified with an empty body, so only a few hundred bytes cross the wire. It gets slow only when a host emits an unstable ETag that forces a full 200 every time.

Do I need tiered caching or an origin shield for a static site?

It is optional but valuable once your audience is globally spread. A flat CDN produces one origin fetch per PoP for each cold object, so a purge can trigger a fan of simultaneous origin requests. A regional or shield tier collapses that to a single origin fetch that fans back out, keeping the post-deploy refresh predictable.