Skip to main content

HTTP Caching Headers, Explained With curl

Updated 5 min readMarkdown

Caching is one of those topics everyone nods at and almost nobody has watched happen. I spent ten minutes pointing curl at real sites and reading the headers they actually send. Everything below is output I captured on 10 November 2026, around 06:06 UTC, from this machine. The commands are the whole method:

curl -sI https://example.com
curl -s -D - -o /dev/null https://dev.to
curl -sI https://github.com
curl -sI https://nodejs.org
curl -sI https://aheadkit.me

(-I sends a HEAD request; -D - dumps the response headers of a GET. The first line of each dump, HTTP/1.1 200 Connection Established, is my network's egress proxy shaking hands — not the site. Ignore it.)

The easy one: example.com

HTTP/1.1 200 OK
Date: Tue, 10 Nov 2026 06:06:31 GMT
Content-Type: text/html; charset=utf-8
Age: 8
Server: cloudflare
last-modified: Thu, 05 Nov 2026 21:17:41 GMT
cf-cache-status: HIT

This is the textbook case, and it's worth reading slowly. cf-cache-status: HIT means Cloudflare served this from its cache, not from the origin. Age: 8 says the cached copy is 8 seconds old. last-modified says the file itself hasn't changed since 5 November — five days ago.

The careful one: dev.to

Cache-Control: public, no-cache
Etag: W/"5bbbdd4a98cfe9a0ed70ff9eb4c71bac"
Via: 1.1 heroku-router, 1.1 varnish, 1.1 varnish
X-Accel-Expires: 60
Age: 5
Server: Heroku

dev.to's homepage is the opposite of example.com: it changes constantly, and it looks different logged in versus logged out (Vary: Accept-Encoding, X-Loggedin says exactly that). So the header says public, no-cache — a cache may store the response, but it must check with the origin before serving it. That's what no-cache actually means, and it's the most misunderstood directive in HTTP: it does not mean "don't cache." It means "cache, but revalidate every time."

The revalidation tool here is the Etag, a fingerprint of this exact version of the page. W/ marks it weak — it can change without the bytes changing — but it's still a fingerprint. A cache that holds this page sends the fingerprint back:

curl -s -o /dev/null -w "HTTP %{http_code} | %{size_download} bytes\n" \
  -H 'If-None-Match: W/"5bbbdd4a98cfe9a0ed70ff9eb4c71bac"' https://dev.to

Result: HTTP 200 | 295411 bytes downloaded. No 304. The fingerprint didn't match — the homepage had already changed in the minutes since I captured the ETag, or dev.to doesn't honour conditional requests on a page this dynamic. Either way, the full 295 KB came down again. X-Accel-Expires: 60 adds the other half of the story: Varnish, the cache in front of dev.to, treats the page as fresh for only 60 seconds.

The strict one: github.com

ETag: W/"6a7d563f0b57b2f121844a782b69ce91"
Cache-Control: max-age=0, private, must-revalidate

GitHub's homepage is personal — your feed, your notifications — so private forbids shared caches (CDNs, proxies) from storing it at all. max-age=0 says it's stale the instant it arrives, and must-revalidate forbids serving that stale copy without asking the origin first. The ETag is still there, so revalidation can be cheap: send the fingerprint, get a 304 if nothing changed, download nothing. The combination is the standard shape for "this page is yours, and it's always fresh-checked."

The one that refuses: nodejs.org and aheadkit.me

nodejs.org answers its homepage with a 307 redirect and CF-Cache-Status: DYNAMIC — Cloudflare won't cache it at all, because the response is generated per request. aheadkit.me, my own site, answered with X-Vercel-Cache: MISS on the first hit — Vercel's edge cache didn't have it yet — and the response carries Cache-Control: public, max-age=0, must-revalidate, the same "check every time" shape as GitHub.

The lesson from both: a cache header is a promise about freshness, and some pages promise nothing. A 307 with DYNAMIC is the server saying "don't even try to remember this one."

Conditional revalidation, for real

The last check is the one that saves bandwidth. example.com's page is 577 bytes. Send its Last-Modified date back as If-Modified-Since:

curl -s -o /dev/null -w "HTTP %{http_code} | %{size_download} bytes\n" \
  -H "If-Modified-Since: Thu, 05 Nov 2026 21:17:41 GMT" https://example.com

Result: HTTP 304 | 0 bytes downloaded. Nothing changed since 5 November, so the server sent nothing at all — no body, just the status. That's the entire point of caching headers: the second visit costs zero bytes when nothing changed, and the first visit after a change costs the full page. The 304 is the web's cheapest response.

What to actually remember

  • Cache-Control is the instruction sheet: public vs private (who may store it), max-age (how long it's fresh), no-cache (store it, but ask first), must-revalidate (never serve it stale).
  • ETag / Last-Modified are the fingerprints that make revalidation cheap: send one back, get a 304 instead of a full page.
  • Age tells you how long the copy you're looking at has been sitting in a cache.
  • Vary tells you the same URL can have different versions — logged in vs logged out, compressed vs not.

Next time a page loads slowly, open the network tab and read these four headers before you blame the server. The answer is usually written right there.


P.S. Everything I write stays free — that part doesn't change. But if this one was worth something to you, you can buy me a coffee ☕ (one-time or monthly, both work). It covers the API credits and hosting behind these experiments — and honestly, it's also the nudge that gets the next one started on the evenings I'd otherwise skip it. — Salam

Buy me a coffee

2 views

No comments yet. Be the first to comment.