Notes from production
CachingCDNPerformance

HTTP caching with Cloudflare's CDN

The site feels instant on a repeat visit — and it costs us almost nothing to serve. Both of those come from caching: keeping a ready-made copy of each file close to the visitor, so we don't rebuild or refetch it every time. A cache is just a saved copy. A CDN (content delivery network) is a fleet of servers in hundreds of cities that each hold those copies near real people.

Caching here works on two levels, and you tune both:

  • Your codeapps/labs/public/_headers, a plain text file that ships with every deploy. It attaches a Cache-Control header to each file telling browsers and Cloudflare how long a copy stays good. This is the precise, per-file knob.
  • Your Cloudflare dashboard — a handful of toggles that decide how the edge (Cloudflare's nearest server to the visitor) and the origin (your built dist/, the source of truth) talk to each other.

When the two agree, a request usually never travels all the way to the origin at all.

Browser cacheon the device · set by _headersmissCloudflare edge cachehundreds of cities · until next deploymissOrigin · Pagesyour built dist/ · once per deploy↑ a hit at any level answers immediately
Most requests are answered by a cache near the visitor; the origin is hit only on a miss.

The golden rule of cache-busting

Before any policy, one idea decides everything else.

This single distinction is why the cache policy below has exactly two shapes: forever-immutable for hashed files, and short-but-revalidating for everything stable-named.

Cache policy by file type — in the code

Here is the live public/_headers, the file that ships the policy with every deploy. Cloudflare Pages reads it and applies each rule by request path.

# Fingerprinted Vite assets (JS / CSS / fonts) — name changes when content does.
/assets/*
  Cache-Control: public, max-age=31536000, immutable

# App shell (HTML). Short window so repeat visits are instant; revalidate after,
# so a new deploy reaches visitors within ~5 minutes.
/
  Cache-Control: public, max-age=300, must-revalidate
/index.html
  Cache-Control: public, max-age=300, must-revalidate
/id
  Cache-Control: public, max-age=300, must-revalidate
/id/index.html
  Cache-Control: public, max-age=300, must-revalidate

# Stable-named images — fresh for a day, then serve-stale while revalidating.
/logos/*
  Cache-Control: public, max-age=86400, stale-while-revalidate=604800
/favicon.svg
  Cache-Control: public, max-age=86400, stale-while-revalidate=604800
/apple-touch-icon.png
  Cache-Control: public, max-age=86400, stale-while-revalidate=604800
/pangaea-logo.svg
  Cache-Control: public, max-age=86400, stale-while-revalidate=604800
/team/*
  Cache-Control: public, max-age=86400, stale-while-revalidate=604800
/journey/early-webgl-hero.webp
  Cache-Control: public, max-age=86400, stale-while-revalidate=604800

# SEO / crawler files — short cache so edits propagate quickly.
/robots.txt
  Cache-Control: public, max-age=3600
/sitemap.xml
  Cache-Control: public, max-age=3600
/llms.txt
  Cache-Control: public, max-age=3600

What each rule says, in plain words:

  • /assets/* (the hashed JS, CSS and fonts) → public, max-age=31536000, immutable. One year, and immutable means "don't even bother re-checking — this name will never point at different bytes." Safe only because the name changes when the content does.
  • / and /index.html (the app shell — the HTML that boots the site; /id is the same for the Indonesian build) → public, max-age=300, must-revalidate. Five minutes: a repeat navigation right after the first is instant, but must-revalidate means once those five minutes pass the browser re-checks before reuse — so a fresh deploy reaches everyone within about five minutes.
  • /logos/*, favicons, /team/*, the journey .webp images (stable-named pictures) → public, max-age=86400, stale-while-revalidate=604800. Fresh for a day; for a week after that the browser may show the slightly-old copy instantly while it refreshes in the background. The visitor never waits, and the new image arrives on the next view.
  • /robots.txt, /sitemap.xml, /llms.txtpublic, max-age=3600. One hour, so an edit to the sitemap or the crawler files propagates fast.

In Cloudflare — the dashboard

The _headers file does the precise work; the dashboard sets the climate it runs in. The one thing that matters most: tell Cloudflare to respect your headers, so a blanket dashboard setting can't quietly overrule the per-file policy you just tuned.

Do

  • Set Browser Cache TTL → Respect Existing Headers, so each file keeps the exact policy from _headers.
  • Leave Tiered Cache and Always Online on — they cut origin fetches and keep the site up.

Don't

  • Let a dashboard "Browser Cache TTL" value override your tuned Cache-Control — it would flatten a year-long asset and a five-minute shell to the same number.
  • Re-minify with Auto Minify when Vite already minifies.

How Pages caches by default

Do I need R2 or Cloudflare Images for my logos?

Putting it together

Two small things — a text file in the repo and four toggles in a dashboard — are what make the site feel instant on every repeat visit and almost free to serve. Hashed assets live a year in cache and never need re-checking; the HTML shell stays fresh within five minutes of a deploy; stable images serve instantly and refresh quietly. Most requests are answered by a copy near the visitor, and the origin is touched roughly once per deploy.

This is Part 4 (caching) of the deploy series: Part 1 · Point the domain at Cloudflare, Part 2 · CI/CD with GitHub Actions → Pages, Part 3 · Root → www, and the security sibling SSL & security on the edge. The plain-English diary note is Make it fast and safe →.

Sources

  1. Cloudflare Pages — Serving Pages (caching & defaults)
  2. Cloudflare Pages — the _headers file
  3. MDN — Cache-Control