Caching and headers: why you still see the old site
Read and set Cache-Control with confidence, understand ETags and 304s, know the difference between the browser cache and the Cloudflare edge, and diagnose a deploy that looks like it never happened.
12 cards · 6 quiz questions · 9 min read
You push a change to one of your Pages sites, the build goes green, and the page looks exactly as it did before. You hard-refresh. Sometimes that fixes it, sometimes it does not. Someone suggests “purge the cache”, which sometimes works too. This article replaces that guesswork with a model: there are several caches, each with its own rules, and the headers your site sends decide what every one of them does.
There is never only one cache
A request from a visitor’s laptop to one of your Pages sites passes through at least three places that can answer it without troubling your build output:
| Layer | Whose copy | Can you clear it? |
|---|---|---|
| Browser cache | That one person, that one device | No. Never. |
| Cloudflare edge cache | Everyone near that data centre | Yes, by purging |
| Corporate or ISP proxy | Everyone on that network | No |
That table explains most cache confusion. A deploy replaces what your origin would serve. It does not reach into anything already stored. And the layer you cannot clear — the browser — is the one that bites hardest, because a bad header you shipped last month is still being honoured today on machines you will never see.
Cache-Control, directive by directive
Cache-Control is a response header that tells every cache along the path what it may do. The directives that actually matter to you:
Cache-Control: public, max-age=31536000, immutable
Cache-Control: public, max-age=0, must-revalidate
Cache-Control: no-cache
Cache-Control: no-store
Cache-Control: public, max-age=60, s-maxage=3600
Cache-Control: max-age=60, stale-while-revalidate=600
max-age=N— this response is fresh for N seconds. Within that window a cache serves it without asking anyone.s-maxage=N— the same, but for shared caches only (a CDN), and it overridesmax-agefor them. This is how you let Cloudflare hold something for an hour while browsers re-check every minute.no-store— do not write this anywhere. For personalised or sensitive responses.no-cache— badly named. You may store it; you may not reuse it without checking with the server first. This still gives you cheap revalidation, so it is almost always what people mean when they reach forno-store.immutable— do not revalidate even on a reload. Only safe when the URL’s content genuinely can never change.stale-while-revalidate=N— after expiry, keep serving the stale copy for up to N more seconds while fetching a fresh one in the background. Nobody waits; some people see slightly old data.
If you send no Cache-Control at all, caches do not give up — they apply heuristic caching. MDN’s example: a response whose Last-Modified is a year old may be cached for roughly 10% of that age, about 36 days. Silence is not a safe default.
Freshness versus validation
There are two separate questions, and conflating them causes most of the confusion.
Freshness is “may I serve this without asking?” — decided by max-age and friends. Validation is what happens once it is stale: the cache asks the server “is this still current?” rather than re-downloading.
Validation runs on an ETag, a fingerprint of the body:
# First request
curl -sI https://example.pages.dev/index.html | grep -iE 'etag|cache-control'
# etag: "ba0c7e2f1a4..."
# cache-control: public, max-age=0, must-revalidate
# What the browser sends next time, and what comes back
curl -sI -H 'If-None-Match: "ba0c7e2f1a4..."' https://example.pages.dev/index.html
# HTTP/2 304 <- no body at all
A 304 Not Modified carries headers and nothing else. You pay one round trip instead of a whole document. That is why no-cache plus an ETag is a sensible, cheap default for HTML.
The hashed-filename bargain
Your Astro builds emit files like _astro/index.a1b2c3d4.js. The hash is computed from the file’s contents, so any code change produces a different filename. This is the whole trick: cache invalidation by renaming. A cache never has to expire anything, because the new build asks for a URL nobody has ever seen.
# Safe: the name changes whenever the bytes change
/_astro/*
Cache-Control: public, max-age=31536000, immutable
# Not safe: same name forever, and it is the file that lists the hashes
/index.html
Cache-Control: no-cache
index.html is the pointer. Cache the pointer and a returning visitor keeps being handed last week’s list of bundles — bundles your new deploy no longer contains. The symptom is a blank page with 404s for .js files in the console, on some people’s machines only. This is the single most common self-inflicted caching wound, and it is unfixable after the fact.
On Cloudflare Pages, the _headers file in your build output sets this up. Path pattern on one line, indented header lines beneath; up to 100 rules, 2,000 characters per line. One real catch: _headers rules are not applied to responses generated by Pages Functions. If your site has an API route under /functions, set its headers in the handler.
// functions/api/summary.ts — headers must be set here, not in _headers
export const onRequestGet: PagesFunction = async () => {
const body = JSON.stringify({ responses: 1284 });
return new Response(body, {
headers: {
"content-type": "application/json",
// edge holds it 5 minutes, browsers re-check every 30s
"cache-control": "public, max-age=30, s-maxage=300",
},
});
};
What Cloudflare actually does by default
Two different defaults, and mixing them up sends people purging the wrong thing.
For a domain proxied through Cloudflare’s CDN in front of your own origin, Cloudflare caches by file extension from a fixed list (images, fonts, CSS, JavaScript and so on) and its docs state the CDN does not cache HTML or JSON by default. Where a response is eligible and carries no cache directive, the default edge TTL for a 200 is 120 minutes.
For Cloudflare Pages, the behaviour is its own: Pages serves cacheable assets with Cache-Control: public, max-age=0, must-revalidate, always sends an ETag on 200 responses, and answers matching If-None-Match requests with 304. Deployed assets stay on the CDN until your next deployment — but the docs are explicit that the cache is not automatically purged on deploy, and that if stale assets appear you may need a manual purge.
That default is conservative and correct. Its cost is that your immutable hashed bundles pay a revalidation round trip on every page load, which is exactly what the _headers block above fixes.
Vary, briefly
Vary names the request headers a response depends on, so a cache knows to keep separate entries. Vary: Accept-Language means the English and French versions are different cache entries.
Two things to know. Cloudflare’s edge does not build variants for arbitrary Vary headers; serving different image formats off Vary: Accept needs the opt-in “Vary for Images” feature. And Vary: Cookie on anything cacheable is a trap: the cookie is near-unique per visitor, so either your cache key fragments into uselessness, or a layer that ignores the header serves one logged-in person’s page to everybody.
Purging
Cloudflare’s purge options include purge everything, by single URL (their recommended default), by prefix, by hostname and by cache tag. Purge by URL is surgical. “Purge Everything” forces every asset on the zone to be re-fetched, so your origin takes a load spike and visitors hit a cold cache.
Keep one thing front of mind: purging never touches browser caches. Purge fixes today’s symptom at the edge. Only correct headers stop it happening again.
Diagnosing “I deployed but still see the old site”
Work in this order, and stop as soon as you know which layer is lying.
# 1. Is the change even in the deployment? (check the build log / preview URL)
# 2. What is the edge saying?
curl -sI https://mysite.pages.dev/ | grep -iE 'cf-cache-status|^age|etag|cache-control'
# cf-cache-status: HIT <- served from the edge, not your origin
# age: 5400 <- the edge has held this for 90 minutes
# cache-control: public, max-age=0, must-revalidate
# 3. Private window. Fresh there? Then it is YOUR browser, and no purge helps.
# 4. Bust the URL to isolate the cache key
curl -sI "https://mysite.pages.dev/?cachebust=$(date +%s)" | grep -i cf-cache-status
# cf-cache-status: MISS <- original URL was cached; this one was not
cf-cache-status: HIT with a large age means the edge holds it: purge that URL. MISS or DYNAMIC on a stale-looking page means the edge is not the culprit, so look at your browser and at the deployment itself. A fresh private window is the fastest way to separate “everyone sees the old site” from “I see the old site”.
What to check in your own code
- Does your repo have a
_headersfile (or equivalent header config)? If an agent wrote one, read it line by line. - Grep it for
max-age=31536000orimmutableand confirm every matching path is content-hashed. Any rule matching/*,/index.html,/*.htmlor a JSON API is a bug. - Confirm
index.htmland any HTML route resolve tono-cacheormax-age=0, must-revalidate— not a longmax-age. curl -sIyour production URL and readcache-control,etag,ageandcf-cache-statusback. Do this once per site; it takes a minute and tells you what you are actually shipping.- Look for
Cache-Controlset inside Pages Functions or Workers. Anything reading a cookie, a session or a user id must beno-storeorprivate, and must not carryVary: Cookieas a substitute. - Check any long-lived cache on a D1-backed or KV-backed endpoint: decide explicitly how stale that data may be, and write the number in a comment.
An agent adds `Cache-Control: public, max-age=31536000, immutable` to every response from your Astro site, including `index.html`. What goes wrong?
Sources
- MDN Web Docs — HTTP caching website Backs the freshness-versus-validation model, heuristic caching, no-store versus no-cache, and the hashed-asset versus index.html pattern.
- MDN Web Docs — Cache-Control (HTTP header reference) website Exact directive semantics for max-age, s-maxage, immutable, must-revalidate and stale-while-revalidate.
- Cloudflare — Cloudflare Pages: Serving Pages website States that Pages serves cacheable assets with `public, max-age=0, must-revalidate` and always sends ETags, and that the cache is not purged on deploy.
- Cloudflare — Cloudflare default cache behavior website Confirms Cloudflare caches by file extension, does not cache HTML or JSON by default, and applies a 120-minute default edge TTL to 200 responses.