Mehboob Ali
← All writing

The edge said HIT. The browser asked anyway.

/CloudflarePerformance

I was auditing this site’s own performance and everything looked fine. Lighthouse was returning 98 on mobile and 100 on desktop. No layout shift, no blocking JavaScript, eight requests for the whole homepage. So I nearly stopped there.

Then I read the response headers instead of the score.

cf-cache-status: HIT
cache-control: public, max-age=0, must-revalidate

Both of those are about caching, and they describe different parts of the request path. I had only been reading one of them.

Two layers, and I was reading the wrong one

cf-cache-status: HIT is a report. It says Cloudflare’s edge had the file and served it without going anywhere else. That part was working, and it was the line I had been treating as the answer.

Cache-Control is not a report. It is a directive, and it applies to the caches downstream of the edge, the browser’s among them. max-age=0, must-revalidate tells those caches they may store the response but have to validate it before reusing it.

So the edge was serving from cache and saying so, while the response it served told my browser to check back every time. A returning visitor was making a conditional request for the stylesheet and all five font files: six requests, six 304 Not Modified responses, no asset bodies transferred. The page then rendered from bytes the browser already had.

Nothing was re-downloaded, so this was never a bandwidth problem. Six validation requests still add latency for a returning visitor though, more so on a higher-RTT connection. It didn’t show up in my Lighthouse run because the default navigation audit clears storage and measures a cold load.

The default is the default for a good reason

That header is not a misconfiguration. It is what Cloudflare Workers static assets sends when you have not said otherwise, and it is documented as the default.

It is also the correct choice for the general case. Cloudflare has no idea what you are serving. If /logo.png can be replaced tomorrow with different bytes under the same name, then caching it for a year is how you strand people on a stale file with no way to tell them. Revalidating every time is the safe default for a path whose contents can change.

My files are not that.

Fingerprinted names are the whole argument

Astro emits assets like this:

/_astro/Nav.DF0zyjsx.css
/_astro/fraunces-latin-wonk-normal.sgo-ioRB.woff2

Astro fingerprints its build output, so that middle segment changes when the built asset changes. Edit the CSS and the next build emits a different filename, and the HTML that references it is rebuilt to point at the new one.

The old URL is not updated in place. It goes on identifying the old build output, and the changed asset arrives at a new address. So a long lifetime on /_astro/* is not a bet that the file won’t change. Whatever is at that URL is not the thing that gets replaced.

The default is right for the general case and wrong for this one, and saying so took one file:

/_astro/*
  Cache-Control: public, max-age=31536000, immutable
Before
Revalidate, always
After
One year, immutable

immutable is worth adding deliberately. max-age on its own says the response stays fresh for a year. immutable says something narrower: while it is fresh, there is nothing to be gained by validating it, because the response at this URL is not going to change. For a fingerprinted asset that is already true. This just states it instead of leaving the browser to work it out.

What I deliberately left alone

The rule is scoped to /_astro/* and nothing else, and that scoping is most of the thinking.

The resume PDFs live at stable paths like /resume/Mehboob_Ali_Staff_Principal_Tech_Lead_Resume.pdf. The whole point of a stable path is that I can replace the file behind it and every link that already exists keeps working. Same for the Open Graph image. Those are precisely the files that must keep revalidating, and putting a year of immutable on them would mean someone who downloaded my resume in August gets served the August copy in December, from their own disk, with no way to know.

The header file is small enough to be worth reading in full before you write it: 100 rules maximum, 2000 characters a line, and it is never served as an asset itself. Where you redefine a header, your value replaces the platform default for that header. Anything you don’t name is left alone, so setting Cache-Control here doesn’t disturb the rest of the response.

Checking takes one command

curl -sI https://yoursite.com/_astro/<fingerprinted-asset>.css | grep -i cache-control

If you deploy static assets to Cloudflare and have never run that, I would guess you get max-age=0, must-revalidate and a cf-cache-status: HIT sitting three lines above it, looking like reassurance.

What I got wrong wasn’t the header. It was reading a status line as the measurement. cf-cache-status: HIT was true; it answered a question I hadn’t asked.

An hour later I did it in the opposite direction. Search Console reported Couldn't fetch on a sitemap I could fetch myself in 200ms, and went looking at bot rules before noticing the “Last read” column was empty. Nothing had failed. Nothing had happened yet.

Both times the dashboard was correct about something adjacent to what I wanted to know.