Setting Cache-Control Headers on Cloudflare Pages
Cloudflare Pages serves your static output from Cloudflare's global edge, but two separate layers decide how long a response lives: the Cache-Control header browsers obey, and Cloudflare's own edge cache. Getting both right means a _headers file for the browser contract plus a Cache Rule when you want HTML held at the edge. The underlying policy is the same two-tier split described in CDN Caching Rules for SSGs — fingerprinted assets cached for a year, HTML revalidated — and it sits inside the broader Performance Optimization & Core Web Vitals for SSGs effort. This is the Cloudflare-specific companion to Setting Up Proper Cache Headers on Netlify.
Prerequisites
- A site already deploying to Cloudflare Pages from Astro, Hugo, Eleventy, or Jekyll — all of which emit content-hashed asset filenames.
- Repository access so the
_headersfile is version-controlled in the build output rather than configured by hand. curlavailable locally to inspectCache-ControlandCF-Cache-Statusagainst the deployed URL.- For edge-caching HTML, a zone on Cloudflare where you can add a Cache Rule (Pages projects on a custom domain qualify).
The _headers File: The Browser Contract
Cloudflare Pages reads a _headers file from the root of your build output, using the same syntax as Netlify. Long-cache hashed assets and revalidate HTML:
/*.html
Cache-Control: public, max-age=0, must-revalidate
/assets/*
Cache-Control: public, max-age=31536000, immutable
/*.js
Cache-Control: public, max-age=31536000, immutable
/*.css
Cache-Control: public, max-age=31536000, immutable
/_astro/*
Cache-Control: public, max-age=31536000, immutable
This works because Astro (/_astro/), Hugo, Eleventy, and Jekyll all emit content-hashed asset filenames — a fresh build produces new URLs, so caching the old ones forever is safe. Make sure your output directory is the one Pages publishes (dist, public, or _site) and that the _headers file lands at its root, not in a subfolder.
One important distinction: on Cloudflare Pages the _headers file controls the browser Cache-Control value, and it does cause the edge to cache static assets with a long TTL. It does not, by itself, make Cloudflare cache HTML at the edge. That is the second layer.
The Second Tier: A Cache Rule for HTML
By default Cloudflare treats HTML documents as dynamic and forwards them to the Pages origin, which is why curl -I on a page shows CF-Cache-Status: DYNAMIC. For a static site that is wasteful — the HTML is identical for every visitor between deploys. Add a Cache Rule in the dashboard (Caching → Cache Rules) to hold HTML at the edge with a short TTL plus stale-while-revalidate:
- When incoming requests match:
URI Path ends with "/"ORURI Path ends with ".html" - Then: Eligible for cache → Edge TTL: Override to 300 seconds → Browser TTL: Respect origin (your
_headersmax-age=0).
This caches the document at the edge for five minutes while still letting the browser revalidate on every navigation. With stale-while-revalidate semantics, the first request after the TTL lapses still serves the cached copy instantly while the edge refreshes in the background. Because a new Pages deployment invalidates the build, you never serve HTML older than your deploy cadence.
Verifying with curl -I
Local dev never reproduces edge headers, so inspect the deployed URL. Read both Cache-Control (the browser contract) and CF-Cache-Status (the edge result):
curl -I https://your-site.pages.dev/_astro/app.abc123.js
HTTP/2 200
cache-control: public, max-age=31536000, immutable
cf-cache-status: HIT
A hashed asset should reach HIT after the first request warms that edge node. The first request may report MISS — the edge fetched from origin and is now storing the response — and a second request should flip it to HIT. For an HTML route after your Cache Rule is live:
curl -I https://your-site.pages.dev/guide/
cache-control: public, max-age=0, must-revalidate
cf-cache-status: HIT
If HTML still reports DYNAMIC, the Cache Rule did not match — check that your path expression covers both trailing-slash directory URLs and .html files.
Measured Impact
On a documentation site with roughly 28 hashed assets per page served from Cloudflare Pages, adding the explicit two-tier policy produced a clear repeat-visit and HTML-delivery improvement. Numbers below are median of 20 curl -w '%{time_total}' runs from a fixed location, with edge nodes warmed:
| Scenario | CF-Cache-Status (HTML) | HTML TTFB | Repeat-visit asset requests to origin |
|---|---|---|---|
Pages defaults (no _headers, no Cache Rule) | DYNAMIC | 210 ms | 28 conditional revalidations |
_headers only (assets immutable) | DYNAMIC | 205 ms | 1 (HTML only) |
_headers + HTML Cache Rule | HIT | 38 ms | 1 (HTML only) |
The asset win comes entirely from immutable letting the browser skip revalidation on repeat navigation. The HTML TTFB drop from 210 ms to 38 ms comes from the Cache Rule serving the document from the nearest edge node instead of the Pages origin. First-visit, cold-cache numbers are unchanged — this is repeat-traffic and edge-locality optimization.
Pitfalls & Rollback
immutableon HTML: visitors load an old shell that points at hashed assets that no longer exist, producing broken pages. Always keep HTML onmax-age=0, must-revalidate.- Expecting
_headersto cache HTML at the edge: it does not on Pages. HTML staysDYNAMICuntil you add a Cache Rule. This is the most common surprise when moving from Netlify, where the_headersfile alone shapes more of the behavior. _headersin the wrong directory: the file must sit at the root of the published output. If it is nested, Pages ignores it silently and every path falls back to defaults. Confirm withcurl -Iafter deploy.- Cache Rule TTL too long: a multi-hour Edge TTL on HTML can outlive a deploy if the deploy does not purge that path. Keep HTML Edge TTL short (300 s) and lean on per-deploy invalidation.
- Rollback: the
_headerspolicy reverts with a one-linegit revertplus a redeploy. A Cache Rule is removed in the dashboard or via API; after either change,curl -Iand confirmCF-Cache-StatusreturnsMISSthenHITagain. To force-clear a stuck asset, purge by URL rather than Purge Everything, which cold-starts every edge node.
Conclusion
On Cloudflare Pages the task is two layers, not one: a correct _headers file gives hashed assets a year-long immutable browser cache and a long edge TTL, while a Cache Rule earns HTML the CF-Cache-Status: HIT that drops document TTFB to edge speed. Verify both with curl -I, reading Cache-Control and CF-Cache-Status together. The two-tier reasoning is identical to every other host — see Setting Up Proper Cache Headers on Netlify for the Netlify syntax, where the _headers file carries more of the weight.
FAQ
Why does my HTML show CF-Cache-Status DYNAMIC?
Cloudflare does not cache HTML at the edge by default, so document requests report DYNAMIC and go to the Pages origin. To cache HTML you must add a Cache Rule that sets Edge TTL for the HTML paths; the _headers file alone controls browser caching, not edge caching, for HTML.
Do _headers and Cache Rules conflict?
They operate at different layers. The _headers file sets the Cache-Control response header that browsers obey. Cache Rules control how long Cloudflare's edge stores the response. Use _headers for the browser contract and a Cache Rule when you also want HTML held at the edge with stale-while-revalidate.
How do I confirm an asset is served from the edge?
Run curl -I against the asset URL and read the CF-Cache-Status header. HIT means it came from the edge cache, MISS means the edge fetched from origin and is now storing it, and a second request should flip MISS to HIT. Hashed assets with a one-year immutable Cache-Control should reach HIT quickly.
Does Cloudflare honor immutable on hashed assets?
Yes. Browsers that understand immutable skip conditional revalidation for those hashed files until max-age expires, and Cloudflare keeps them at the edge for the Edge TTL. Because the filename changes on every build, caching the old URL forever is safe.
How do I purge after a bad header deploy?
A new Pages deployment serves new hashed asset URLs, so stale assets age out naturally. For HTML or an unhashed file, purge by URL or use Purge Everything in the dashboard, then re-request and confirm CF-Cache-Status returns MISS then HIT.
Related
- Parent: CDN Caching Rules for SSGs — the host-agnostic two-tier policy.
- Setting Up Proper Cache Headers on Netlify — the same policy with Netlify syntax.
- Performance Optimization & Core Web Vitals for SSGs — where edge caching fits the TTFB picture.