Why your CDN caches nothing: the header that blocks it

Every static file on agentready.md was coming back with the same line:

cf-cache-status: BYPASS

Fonts, stylesheets, scripts, images. Not MISS — a MISS means the edge had not seen the file yet and will keep the copy it just fetched. BYPASS means the edge read the response, decided it was not allowed to store it, and would decide the same thing tomorrow. Every visitor was pulling every byte from the origin, and would have kept doing so forever.

The four settings that were not the problem

  • Development Mode — off. It disables the edge cache for three hours and people leave it on, so check it first.
  • Caching Level — Standard.
  • Page Rules — none of them set Cache Level to Bypass.
  • Browser Cache TTL — a sane value.

Everything the dashboard had to say about caching said cache this. The edge cached nothing.

The answer was in the response

It shows up the moment you read a whole response header block instead of the one line you went looking for:

curl -sI https://agentready.md/fonts/ibm-plex-sans-latin.woff2
HTTP/2 200
content-type: font/woff2
cache-control: public, max-age=31536000, immutable
set-cookie: _csrf=8f3c…; Path=/; SameSite=Strict
cf-cache-status: BYPASS

Line three asks for a year. Line four is why nobody granted it.

A response carrying Set-Cookie is not cacheable in a shared cache. A cookie belongs to one visitor; storing that response and handing it to the next person hands them somebody else's cookie. Every CDN refuses. Cloudflare names the refusal BYPASS; others just quietly do not store the object.

The cookie was ours. A middleware generated a CSRF token on every request and attached it, so that a form rendered anywhere on the site would find one waiting. "Every request" included the request for a woff2 font — a file that renders no form, reads no token and runs no code.

The fix

Not weaker CSRF. Just stop issuing tokens to things that cannot use one:

const ASSET_PREFIXES = ['/css/', '/js/', '/fonts/', '/images/'];
const COOKIELESS_FILES = new Set([
  '/favicon.ico', '/badge.js', '/robots.txt', '/sitemap.xml', '/llms.txt',
]);

function servesWithoutCookie(path) {
  return COOKIELESS_FILES.has(path)
    || ASSET_PREFIXES.some((prefix) => path.startsWith(prefix))
    || path.endsWith('.md');
}

Match on path, not on file extension. Some of the responses that most want caching are generated rather than read off disk — our Markdown twin of each page, our SVG badges — and they never render a form either.

If the cookie is not one you wrote, look at your session layer. Plenty of frameworks start a session, and send its cookie, on every request that reaches the middleware, whether or not anything was stored in it.

Diagnosing it on any stack

Check an asset, not the homepage. A homepage is often uncacheable for legitimate reasons, and it tells you nothing about the thirty files it pulls in.

for u in / /css/app.css /js/app.js /fonts/ibm-plex-sans-latin.woff2; do
  echo "== $u"
  curl -sI "https://example.com$u" \
    | grep -iE 'cache-control|set-cookie|cf-cache-status|x-cache|^age'
done

Request the same URL twice. The second should be a hit: cf-cache-status: HIT, x-cache: Hit from cloudfront, or an age: header that climbs on Varnish and nginx. A second miss means the object is not being kept, and the reason is in the headers you just printed.

The other usual suspects

Cache-Control: private — browsers only, never a shared cache. no-store — nobody, not even the browser. no-cache does not mean "do not cache"; it means revalidate before every reuse, still a round trip per asset.

Vary: * makes a response uncacheable outright. Vary: Cookie is nearly as bad: the cache key now holds a value that differs per visitor, so nothing is ever shared. Vary: User-Agent shatters one file into thousands of variants. Accept-Encoding is the one that belongs there.

Query strings. Most caches key on the full URL, so every ?utm_source= and ?fbclid= variant of a page is a separate object fetched from origin. Strip or normalise marketing parameters at the edge.

And the framework default, which is not a bypass but costs almost as much: max-age=0 on every static file means a revalidation request per asset per navigation.

A cache policy worth having

The rule is what the URL promises, not what the file is.

A URL carrying a content hash — app.css?v=9f2ac41b, app.9f2ac41b.css — is a content address: change the file and it is a different URL. Those get public, max-age=31536000, immutable, and there is never anything to purge. The same file without the token gets an hour: the token is the promise, and nothing should be cached on a promise nobody made. A logo or a social cover, replaced in place, gets a week.

Then the exception, which people get wrong because the file looks like the most cacheable thing they own: anything embedded in other people's pages on a URL you cannot version. We serve badge.js for an hour for that reason alone. It runs inside sites where we control neither the HTML nor the URL, so a year of immutability would mean a bug we cannot reach for a year.

If you serve files with @fastify/static

Two specifics that cost us an afternoon. It sends Cache-Control: public, max-age=0 by default. And if you set the header yourself through setHeaders, pass cacheControl: false as well, or the library builds its own header from its default maxAge and overwrites yours — silently, leaving something that looks deliberate:

app.register(fastifyStatic, {
  root: join(__dirname, 'public'),
  cacheControl: false,           // or setHeaders is overwritten
  setHeaders: setStaticCacheHeaders,
});

Before touching a single cache setting, read one response in full. The header that breaks caching is rarely the one with "cache" in the name.

Check your site · AI-readiness checklist · Are the bots allowed in?