Performance

Caching Strategy for a Static Site: The Layers You Actually Control

There is a piece of advice in Cloudflare’s Pages documentation that reads like a shrug: “In most situations, you should avoid setting up any custom caching on your site.” It goes on to warn that custom caching “may lead to stale assets being served after a deployment,” and recommends purging everything as the remedy.

That is unusually blunt guidance from a CDN. Most platforms would rather you believed caching is a knob you should be turning. So it is worth taking seriously — and then working out exactly where it applies, because “avoid caching” is not the same instruction as “caching is already handled.”

What is cached for you, before you configure anything

Cloudflare caches by file extension, not by MIME type. That distinction is the root of most confusion about what is cached, because a response can be perfectly cacheable and still be ignored if its URL does not end in something on the list.

CSS, JavaScript, web fonts, SVG, and the common image formats are all on it. robots.txt is too, which surprises people.

And then the line that decides the architecture of a static site: “The Cloudflare CDN does not cache HTML or JSON by default.”

Sit with that for a second. Your HTML — the one thing that changes with every deploy — is served fresh, from origin, every time. Your assets — the things with content hashes in their filenames that never change — are cached at the edge. That is the correct default, and it is the reason a static site can deploy confidently: there is no HTML layer to go stale.

Reading cf-cache-status without guessing

Nine values are documented, and they are not synonyms:

ValueMeaning
HITServed from edge cache
MISSNot in cache; fetched from origin
EXPIREDCached copy was past its TTL
STALEServed expired content while revalidating
REVALIDATEDConfirmed with origin that the copy is still valid
UPDATINGServing stale while asynchronously refreshing
DYNAMICNot eligible for cache at request time
BYPASSEligible, but the origin response was not cacheable
NONE / UNKNOWNNo cache information available

The two that get conflated are DYNAMIC and BYPASS. DYNAMIC means the request was never a cache candidate — which is exactly what you should see on your HTML. BYPASS means it could have been cached but the response told Cloudflare not to. One is a design decision; the other is usually a misconfiguration.

On my own site, measured with curl -sI: the compiled stylesheet reports REVALIDATED and the homepage HTML reports DYNAMIC. Both are correct behaviour, and neither is a problem to fix.

The three TTLs, and which ones are yours

SettingWhere it livesApplies toNotes
Cache-Control: max-ageYour _headers fileBrowserThe end user’s copy
Cache-Control: s-maxageYour _headers fileEdge onlyBrowsers ignore it entirely
Edge Cache TTLZone Cache RulesEdgeMinimum 2 hours on Free; never appears in response headers
Browser Cache TTLZone Cache RulesBrowserCloudflare honours whichever value is higher

Two operational notes fall out of this table.

You cannot verify Edge Cache TTL with curl. It does not surface in response headers, and the minimum on the free plan is two hours. If you are trying to confirm it by inspecting a response, you are looking in the wrong place.

Cloudflare respects the higher value. If your zone sets a Browser Cache TTL and your _headers file sets a smaller max-age, the zone setting wins. That is a quiet way for a well-intentioned header to have no effect, and it is worth knowing before you conclude your _headers file is broken.

The combination that cancels itself

Here is the one I would have got wrong from memory. On Cloudflare, s-maxage implies proxy-revalidate, which means it disables stale-while-revalidate. Setting both is not belt and braces — it is one directive quietly switching off the other.

And stale-while-revalidate itself does not behave the way most articles describe. It is now fully asynchronous: the first request after expiry gets the stale copy and the response reports UPDATING, rather than the request blocking while the cache refreshes.

If your goal is “keep serving something, refresh in the background,” s-maxage is the wrong tool.

What you can actually set, and how

_headers can set Cache-Control, and the official documentation has an example titled exactly this — “Configure custom browser cache behaviour”:

/static/*
  Cache-Control: public, max-age=31556952, immutable

Cloudflare states that headers defined in _headers override what it ordinarily sends.

There is exactly one documented case where _headers rules do not apply, and it is narrower than the folklore suggests: responses generated by Pages Functions. Not cached responses, not preview deployments — Functions. If you are running a purely static site, that caveat never fires.

That matters because “you can’t control caching with _headers on Pages” is a claim I have seen repeated, and I could not find it in the documentation. If you have read that somewhere, verify it on your own deployment before you rebuild your whole approach around it.

Why “purge everything” is not as wasteful as it sounds

The documented per-PoP behaviour of Pages assets is odd: “Assets have a time-to-live (TTL) of one week but can also disappear at any time,” and “if you do a new deploy, the assets could exist in that data center up to one week.”

So a deploy does not instantly evict the previous version everywhere. Some data centres can hold the old files for up to a week. That is the mechanism behind the “stale assets after deploy” warning, and it is why the recommended fix is a purge.

Purge limits, per account on the free plan: single-file purges run at 800 URLs per second with a maximum of 100 operations per request; hostname, prefix, tag, and purge-everything are limited to 5 requests per minute with a token bucket of 25. Also documented, and worth internalising: a 200 response means the request was received, not that anything was evicted.

When custom caching makes things worse

The honest case against tuning this: content-hashed assets are already cached correctly, and HTML is already uncached correctly. There is not much left to win.

Setting a long max-age on HTML is the classic own goal. You gain a small TTFB improvement for repeat visitors and you take on a new problem — readers seeing a page you replaced, with no way to know. The failure mode is not “slightly stale,” it is “the site is wrong and I cannot tell which visitors see the wrong version.”

The place where it does pay off is a stable asset path that never changes: a favicon, a logo, a font you have pinned. If the URL never changes and the bytes never change, immutable is free money. If the URL never changes and the bytes do, immutable is a support ticket.

The short version

Most of the caching on a static site is already correct, and your platform’s advice to leave it alone is not laziness — it is a description of the default. Check cf-cache-status on one HTML page and one hashed asset to confirm the shape, set immutable on the handful of stable assets that deserve it, and spend the rest of the afternoon on something that a visitor will notice.


Written by TestedHost. Every recommendation on this site comes from running the setup described, on a live deployment — not from a vendor spec sheet. Spotted something out of date? Tell us.