Hosting and Caching

How Cloudflare Pages serves the sites, what gets cached where, and how cache invalidation works.

≈ 8 min read 1587 words Updated 2026-08-29

On this page

How content moves from origin to browser, and what gets cached at each layer.

Cache layers#

flowchart LR
    browser["Browser cache<br/><i>local</i>"]
    edge["Cloudflare Edge<br/><i>global CDN</i>"]
    origin["Origin (Pages)<br/><i>build output</i>"]
    source["Source (Git)<br/><i>repository</i>"]
    browser --> edge --> origin --> source

Cache configuration by site#

www.wheelofheaven.world#

Asset typeBrowser TTLEdge TTLStrategy
HTML pages0Deploy purgeAlways fresh
CSS/JS1 year1 yearHashed filenames
Fonts1 year1 yearImmutable
Images1 month1 yearLong-lived

api.wheelofheaven.world#

EndpointBrowser TTLEdge TTLHeader
/v1/*1 hour1 hourmax-age=3600
# static/_headers
/v1/*
  Cache-Control: public, max-age=3600

assets.wheelofheaven.world#

Asset typeBrowser TTLEdge TTLHeader
All images1 year1 yearmax-age=31536000, immutable

Images are immutable — updates get new filenames.

Cloudflare cache settings#

Page rules (if needed)#

URL: assets.wheelofheaven.world/*
Setting: Cache Level = Cache Everything
Edge TTL: 1 month
Browser TTL: 1 year

Cache purge#

On Cloudflare Pages deployment, cache for changed files is purged automatically and the edge cache is invalidated globally.

Manual purge via API:

curl -X POST "https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache" \
  -H "Authorization: Bearer {token}" \
  -H "Content-Type: application/json" \
  --data '{"purge_everything":true}'

Browser caching#

HTML documents#

No caching, to ensure fresh content:

Cache-Control: no-cache

Cloudflare serves from edge but validates on each request.

Static assets#

Long cache with hashed filenames:

Cache-Control: public, max-age=31536000, immutable

Zola generates hashed asset URLs, so new versions get new URLs and bypass the cache automatically.

Service worker (PWA)#

The service worker manages its own cache:

  • Cache-first for static assets
  • Network-first for HTML
  • Fallback to offline page

Cache debugging#

Check cache status#

curl -I https://www.wheelofheaven.world/ | grep -i cf-cache

Response header values:

  • cf-cache-status: HIT — served from edge
  • cf-cache-status: MISS — fetched from origin
  • cf-cache-status: DYNAMIC — not cached

Force bypass#

Add a query string to bypass cache:

https://www.wheelofheaven.world/?nocache=1

Or use browser dev tools: “Disable cache” checkbox.

Cache optimization#

  1. CDN for images — all images served from assets.wheelofheaven.world with aggressive caching.
  2. Minimize HTML — keep pages focused; lazy-load images; defer non-critical JS.
  3. Long browser TTLs — consistent URLs for unchanged assets, fingerprinted filenames for CSS/JS.
  4. Edge-first architecture — static generation only; no server-side rendering; everything cacheable.

Known platform quirks#

External-platform bugs and edge cases we’ve hit in production and the working patterns that get around them.

CF Pages content-hash blob poisoning#

Symptom: A specific page URL returns HTTP 500 from Cloudflare Pages with bare headers — no cache-control, no Content Security Policy, no content-type, empty body. The server header is just cloudflare. Other pages on the same site serve fine. The deployment-specific URL ({deploy-hash}.{project}.pages.dev/{path}) 500s identically, so it is not a DNS or apex-routing issue. Fresh deploys (including empty-commit forced rebuilds) reproduce the 500 identically.

Confirming you’re hit:

  1. Hit the URL directly: curl -sI https://www.wheelofheaven.world/path/ — note cf-cache-status: DYNAMIC and the missing transform headers on the 500.
  2. Hit the per-deploy URL from the Pages dashboard ({hash}.www-wheelofheaven-io.pages.dev/path/) — if that also 500s, the file is bad in the deployment artifact itself, not in the serving layer.
  3. Hit the file via raw GitHub on the gh-pages branch — if it returns 200 with valid HTML there, the file is correct at the source and the issue is on the CF Pages side.

Cause: CF Pages content-addresses uploaded assets. When a specific file’s first upload silently fails (corrupts in transit, hits an upload race, or some internal Pages bug), the broken blob is keyed by the file’s content hash. Every subsequent rebuild produces a build with the same hash for the unchanged file → CF Pages reuses the poisoned blob → every fresh deploy reproduces the 500 identically.

Fix: Make a tiny, semantically-neutral content change to the file’s source — a word, a punctuation mark, anything that changes the rendered output. Commit and push. The new content has a new hash, which is not in CF Pages’s poisoned-blob index, so the upload works cleanly and the URL flips to 200.

Example, from the 2026-05-22 incident on Release 02:

-*— Filed May 22, 2026, Wheel of Heaven editorial desk.*
+*— Filed May 22, 2026, by the Wheel of Heaven editorial desk.*

A 7-character addition to the markdown — invisible to the reader, sufficient to bypass the poisoned blob.

Don’t bother first:

  • Retry deployment from the dashboard. Same content hash → same poisoned blob.
  • Empty-commit forced rebuilds. Likewise — the unchanged file hashes the same.
  • Cache purge on the URL. The 500 isn’t cached; CF is passing through to the broken blob on every request.

Don’t bother second:

  • Renaming the slug. This works but leaves the original URL 500’d forever (a 301 won’t help because the 500 happens before any application layer can issue a redirect). The tiny-edit fix preserves the URL.

File a CF support ticket alongside: Bare-500 on a Pages deploy that has the index entry is server-side; their team can purge the poisoned blob and prevent recurrence. Include the deployment ID ({hash}.{project}.pages.dev) and the specific URL.

Hit on: 2026-05-22, /news/pursue-release-02-spheres-and-transmedium/. Two independent fresh builds (0eaf4e14 and 361f583f) both 500’d identically; a one-line content change on the source resolved it on the next deploy.

CF Pages 20,000-file deployment cap#

Symptom: The Cloudflare Pages build runs to completion, the build log shows the Zola output and the postbuild summary, but the deploy step fails with:

✘ [ERROR] Error: Pages only supports up to 20,000 files in a deployment.
          Ensure you have specified your build output directory correctly.
Failed to validate assets in the output directory with code: 1
Failed: error occurred while validating assets in your output directory.

Cloudflare then silently rolls back to the previous successful deployment and continues serving the old build. The failed build’s new URLs return 404 because the files were never uploaded. The site appears to “be deployed” in the sense that traffic still works, but nothing from the new commit is live.

The number is a hard cap on all Pages plans (Free, Pro, Business) as of 2026-05. Workers Static Assets has a higher cap but is a different deploy mechanism.

Counting what hits the cap:

Everything under public/ counts toward the limit, not just leaf content files. Specifically:

  • Each Zola page emits one index.html.
  • The _headers, _redirects, robots.txt, sitemap.xml, llms.txt each count as one file.
  • Any sibling files added by postbuild.sh (e.g. mirroring every index.html as index.json for explicit-extension consumers) count too. The mirror doubles the file count for free, which is the most common way to bump into the cap unintentionally.

Check the current build’s count locally before pushing big page-count changes:

mise run build && find public -type f | wc -l

Working patterns:

  1. Drop redundant file mirrors in postbuild. If _redirects already rewrites explicit-extension URLs (e.g. /v1/*/index.json /v1/:splat/ 200), the sibling .json files are dead weight. Removing the mirror halves the file count.
  2. Make per-language mirrors coverage-honest. Only emit pages for surfaces where translations actually exist. The naive “mirror everything under /v1/{lang}/...” pattern multiplies the English page count by the number of languages, which on a corpus with ~2,000 English pages and 9 languages crosses the cap immediately.
  3. Split into multiple Pages projects. Each project has its own 20K cap, so e.g. api.wheelofheaven.world and assets.wheelofheaven.world are independent budgets. Splitting an over-cap site into two projects is the escape hatch when no other reduction is acceptable.

The api.wheelofheaven.world incident, 2026-05-31: The full multilingual library mirror (039418a — every of 9 langs × 103 books × ~18 chapters) produced 21,249 Zola pages, which the postbuild JSON-mirror doubled to ~46,000 deploy files. CF rejected the deploy; the previous build (890abec, English-only library) stayed live for ~30 minutes while the cap-fit refactor (b67c96e) was prepared. The fix: filter per-language books to those in availableLangs (~14 books across 6 non-English langs instead of 103 × 9), plus drop the .json mirror entirely. Final count: ~3,700 files.

Don’t bother first:

  • Retry from the dashboard. Same files, same cap failure.
  • Empty-commit forced rebuilds. Same.
  • Toggling build cache. Doesn’t affect the post-build file count.

Don’t bother second:

  • CF support ticket asking for an exemption. The cap is enforced per-deployment, not per-account; support can’t lift it on a per-project basis.

Cross-reference: the API repo’s scripts/postbuild.sh and scripts/prebuild.py enforce the working patterns. Don’t reintroduce the JSON mirror without first verifying file count stays under cap.

Low zone cache-hit ratio on Pages is expected#

Symptom: Cloudflare zone analytics reports a cache-hit ratio of 3–13% for www, averaging around 7%. On a fully static site that looks alarming.

Why it’s fine: Pages serves HTML with cache-control: max-age=0, must-revalidate, so the zone cache reports cf-cache-status: DYNAMIC and never counts a hit. But Pages has its own globally distributed serving layer — the “origin” is already Cloudflare. Measured on /wiki/elohim/: uncached HTML TTFB is 71–197 ms against ~40 ms for a zone-cached asset. A 30–60 ms penalty, not the 10× you’d see with a distant origin.

Why we don’t fix it: edge-caching HTML via Cache Rules or s-maxage buys tens of milliseconds and costs stale pages after every deploy. With several sessions pushing content daily, that’s a bad trade. Checked deliberately; leave it alone.

Note that robots.txt does report max-age=14400 — that comes from the zone’s browser_cache_ttl, not from any managed-robots feature. Don’t read it as a signal that Cloudflare is generating the file; check is_robots_txt_managed via the /bot_management API instead.

Cloudflare Web Analytics is provisioned but collects nothing#

Symptom: Web Analytics appears configured for the zone (auto_install: true on the RUM site config), but no data arrives.

Cause: two independent failures. The beacon script is not present in the rendered HTML at all — and even if it were, the site’s own CSP blocks it twice over: script-src 'self' refuses static.cloudflareinsights.com/beacon.min.js, and connect-src has no cloudflareinsights.com entry for the POST.

If you want it working: add both CSP entries in static/_headers and confirm injection. If you don’t: turn the provisioning off, so the dashboard stops implying analytics exist. Either way, don’t assume traffic data is being collected — as of 2026-08 it is not.

Edit this page on GitHub