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 code —
apps/labs/public/_headers, a plain text file that ships with every deploy. It attaches aCache-Controlheader 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.
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, andimmutablemeans "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;/idis the same for the Indonesian build) →public, max-age=300, must-revalidate. Five minutes: a repeat navigation right after the first is instant, butmust-revalidatemeans 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.webpimages (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.txt→public, 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