DEV Community

Cover image for I Expected a 304, After 183 Seconds I Got a 200
Mustafa ERBAY
Mustafa ERBAY

Posted on Originally published at mustafaerbay.com.tr

I Expected a 304, After 183 Seconds I Got a 200

This morning I wanted to check something small: can an article page on my blog tell a browser "I haven't changed" while its content genuinely hasn't? The answer turned out to be: for three minutes it can, after that it cannot.

The reason: the only validator my HTML sends to a browser describes not when the content changed but when Cloudflare put that object into its cache. The stamp is refreshed every three minutes, and at that moment the copy in the reader's browser becomes worthless.

One thing I should say up front: none of the behaviour below is a malfunction. Every layer works correctly on its own, and most of it is a decision I made deliberately. The flaw is not in the layers. It is in the gap between them.

The measurement: same request, two different answers

I picked an article I published on 7 November; the article's own .mdx file hasn't been touched since. First I grabbed the page's Last-Modified value, then I sent two requests to the same address every 20 seconds: one plain, one conditional with that original Last-Modified.

t=0s    cf=HIT      age=0    lm=[05:43:21]   conditional -> 304, 0 bytes
t=20s   cf=HIT      age=20   lm=[05:43:21]   conditional -> 304, 0 bytes
t=61s   cf=HIT      age=61   lm=[05:43:21]   conditional -> 304, 0 bytes
t=122s  cf=HIT      age=122  lm=[05:43:21]   conditional -> 304, 0 bytes
t=163s  cf=HIT      age=163  lm=[05:43:21]   conditional -> 304, 0 bytes
t=183s  cf=EXPIRED  age=     lm=[05:46:24]   conditional -> 200, 259,546 bytes
t=204s  cf=HIT      age=20   lm=[05:46:24]   conditional -> 200, 259,546 bytes
t=347s  cf=HIT      age=164  lm=[05:46:24]   conditional -> 200, 259,546 bytes
t=368s  cf=EXPIRED  age=     lm=[05:49:29]   conditional -> 200, 259,546 bytes
t=429s  cf=HIT      age=61   lm=[05:49:29]   conditional -> 200, 259,546 bytes
Enter fullscreen mode Exit fullscreen mode

For the first 163 seconds I got exactly what I wanted: the server said "your copy is current" and no body came down. Then cf-cache-status flipped to EXPIRED once, Last-Modified jumped from 05:43:21 to 05:46:24, and the same conditional request downloaded 259,546 bytes.

A word about the resolution of that measurement: I sampled every 20 seconds. The transition happened somewhere between 163 and 183, and I saw it at 183. The number in the title is the number I saw; the mechanism's number is s-maxage=180.

To avoid generalising from a single address, I repeated the measurement on the homepage and on an older article. That gave four refresh cycles: 183, 185, 185 and 185 seconds. On the homepage the stamp jumped from 05:49:19 to 05:52:24 and the same conditional request pulled 122,914 bytes; on the older article it jumped from 05:53:26 to 05:56:31, pulling 260,493 bytes. In all four the pattern is the same: 304 inside the window, a full body outside it.

So was the body really identical? Throughout the measurement window, yes. Fetching the same page twice from the application server (127.0.0.1:3040, never touching Cloudflare) produced md5 digests that matched exactly: all 258,432 bytes. Nineteen minutes later, six refresh cycles further on, the digest was still fbd36a65…. What the edge counted as "new" six times over was the same file at the origin six times over.

But that does not mean "my page is stable for months" — and later in this piece I will use that against my own recommendation. My pages are rendered by SSR, and every article page carries a "recent posts" list assembled at request time in its sidebar. The body I captured on 9 November contains a link to an article published on 8 November. So the HTML of an article dated 7 November changes as new posts appear, even though I never touch its file.

Taking the chain apart

In my request chain the HTML passes through three hands: Astro's Node server, the nginx in front of it, and Cloudflare on the outside. I asked each of them separately.

Diagram

The application layer speaks most clearly. For blog, tag and series pages src/middleware.ts stamps Cache-Control: no-store, no-cache, must-revalidate. Anyone reading my code would conclude these pages are never cached. There is no validator either: no ETag, no Last-Modified.

nginx deliberately ignores that sentence. My configuration carries proxy_ignore_headers Cache-Control Expires; the nginx documentation defines that directive as disabling the processing of certain response header fields from the proxied server. The application's no-store therefore stops influencing the caching decision, and a 30-second micro-cache kicks in. Then proxy_hide_header Cache-Control hides the header from the client and substitutes a new value chosen by content type: public, max-age=0, s-maxage=180, stale-while-revalidate=60.

That was my decision — made twice, in fact. The map block that writes Cache-Control by content type dates from the work where I pushed my HTML cache rate from one percent to fifty, and on 30 June I made blog HTML cacheable again. The side effect: the no-store in the application layer no longer tells anybody anything. It just sits there and sends code readers to the wrong place. I have fallen into this trap on this blog before — the story in the piece where it turned out I had written two CSPs and the only policy actually enforced was a third I never wrote is exactly this one.

The response leaving nginx has no validator either. When I bypass Cloudflare and ask the origin directly, these are the headers:

content-type: text/html
x-cache-status: HIT
cache-control: public, max-age=0, s-maxage=180, stale-while-revalidate=60
Enter fullscreen mode Exit fullscreen mode

No Last-Modified, no ETag. One of those has a documented reason: nginx's etag directive is defined in its own documentation as generating an ETag for static resources, and HTML rendered by SSR and proxied from an upstream is not a static resource. The missing Last-Modified is not covered by the documentation at all — that nginx adds no such header of its own to a proxied response is something I know from measurement, not from the docs.

That leaves exactly one layer able to produce a validator: the layer that never sees the content and only knows when it received an object.

Cloudflare isn't hiding this — it's documented

Cloudflare describes this behaviour in its own documentation under the name "Smart Edge Revalidation": when both Last-Modified and ETag are absent from the origin response, it uses the time the object was cached on Cloudflare's global network as the Last-Modified value.

My measurement lines up with that sentence exactly. The stamp was 05:43:21, and age started counting up from that moment. After 183 seconds the object was refreshed and the stamp became 05:46:24. The stamp measures the age of the cache entry, not the age of the content.

There is no surprise on the standards side either. Describing how a cache should evaluate an If-Modified-Since request, RFC 9111 says that when the stored response has no Last-Modified, the cache should use "the stored response's Date field value (or, if no Date field is present, the time that the stored response was received)" to evaluate the condition. Falling back to a timestamp in the absence of a validator is the rule. RFC 9110 openly acknowledges in §13.1.3 that caches do sometimes generate this field themselves.

The real distinction again comes from RFC 9110. Its definition of Last-Modified is precise: the date and time at which the origin server believes the selected representation was last modified. Every normative sentence in §8.8.2.1, which describes how the header is generated, addresses only the origin server. What the edge produces is not the thing in that definition; it is something else wearing the same name.

There is one more detail, and it shows up in the measurement. RFC 9110 requires that for a Last-Modified time to be deduced strong, the cache entry's Date must be at least one second later. In my responses the two land in the same second:

cf-cache-status: MISS
date:          Mon, 09 Nov 2026 06:11:35 GMT
last-modified: Mon, 09 Nov 2026 06:11:35 GMT
Enter fullscreen mode Exit fullscreen mode

The same holds across three separate MISS samples. So this validator is not weak by accident but permanently weak by construction: the stamp and the moment that produced it share a second, so that one-second condition can never be satisfied.

Second finding: a directive I wrote does nothing

The header carries stale-while-revalidate=60. The intent is this: the moment the object goes stale, don't make the first request wait — serve the stale copy and refresh in the background.

The measurement shows that isn't happening. At the refresh moment cf-cache-status came back as EXPIRED, not UPDATING — in all four cycles. Cloudflare documents the reason: s-maxage is listed among the directives that disable stale-while-revalidate, and in that case requests return EXPIRED instead of UPDATING. The warning on the same documentation's cache-control page is even blunter: do not use s-maxage together with stale-while-revalidate.

In my header the two sit side by side. According to the repository history I wrote that line on 29 June, so it has been that way for four months.

So what does this cost

The real cost is the compressed wire size, not 259,546. Three measurements gave 65,989, 65,990 and 66,033 bytes; the median is 65,990, roughly 64 KiB. When the same answer is a 304, no body is transferred at all.

One detail: the body the origin renders is 258,432 bytes; the same body returned from Cloudflare is 259,546. The difference is 1,114 bytes and its source is clear — Cloudflare injects its own script into the HTML, and the ray identifier inside it changes on every response. I had seen this exact number a week ago in a different measurement, guessed that "the cached copy is probably an older render", and left it there; that guess was wrong. The difference is not a render gap, it is edge injection. Two back-to-back responses for the same address differ in 19 bytes: 18 from the two ray identifiers, and 1 from the cache-busting query string I added being reflected into the JSON-LD. (That is why the md5 equality above holds only for the body fetched from :3040.)

I would like to conclude from this that the edge cannot compute a content-derived ETag, but it does not follow: the injection happens on the way out, and what gets written into the cache is the pre-injection body. The edge could digest that if it wanted to. The injection is not proof of anything — only a sign that things at the edge are messier than they look.

The actual proof is elsewhere, in the same site's static assets. The files under /_astro/ are also proxied to the same Node process, and for those files Node does produce a validator:

content-length: 13888
etag: W/"3640-1a1bb813ab8"
last-modified: Sun, 08 Nov 2026 14:12:51 GMT
cache-control: max-age=31536000, public, immutable
conditional request -> 304
Enter fullscreen mode Exit fullscreen mode

That ETag opens up like this: 0x3640 is 13,888, the content length; 0x1a1bb813ab8 is 1794147171000, the modification time in milliseconds — pointing at the same instant as the Last-Modified line. Not a digest derived from content but size plus time, which for hashed, immutable files is more than enough.

This case is the control group of the experiment: when the origin does send a validator, Cloudflare leaves it alone, passing the 8 November stamp through untouched. When it doesn't, the edge invents one. The same server process produces a validator for a file on disk and none for the HTML it renders; the difference isn't that the file is special, it's that the middleware serving a static file has size and mtime ready at hand while nobody has a ready-made stamp for SSR output.

What to do: three paths

There is no bug to fix here, only a trade-off to choose.

First, generate a content-derived ETag at the origin. After rendering the HTML, digest the body, stamp it as ETag, and compare it against the incoming If-None-Match. It sounds clean, and while writing this piece I was thinking in terms of "a validator stable for months". The measurement talked me out of it: because of that recent-posts list in the sidebar, every new article changes the HTML of the entire archive. I publish three articles a day, so a content digest would turn over three times a day. Still better than today — a 183-second refresh changes the stamp roughly 470 times a day, and three versus four hundred and seventy is a real gap. But it has a price: digesting the full body on every request, another pass on top of the CPU I already pay for SSR. On top of that, the overwhelming majority of my traffic is crawlers rather than browsers, and they do not send conditional requests; I would pay the CPU on every request and deliver the 304 saving to almost nobody.

Second, drop s-maxage and set the edge duration elsewhere. Cloudflare's own suggestion points this way: send max-age plus stale-while-revalidate from the origin and control the edge duration with Edge Cache TTL in Cache Rules. Here I have to be careful, because Cloudflare's two pages do not agree: the page that gives the recipe recommends it, while the cache-control page says an Edge Cache TTL rule "overrides s-maxage and disables revalidation directives if present". So this path might resurrect stale-while-revalidate — or kill it again from the same place. Cache Rules also carries a separate switch that turns the behaviour off directly ("Serve stale content while revalidating"). Worth trying, but the outcome is something I learn by measurement rather than from documentation: the only proof of success is seeing UPDATING at the refresh moment.

Third, do nothing. What this setup gives me is that a newly published article appears within three minutes at the latest, and that was a deliberate trade. The price is a rare extra download per reader — because with max-age=0 the browser already revalidates on every navigation, and the extra 64 KiB only lands if the same reader returns to the same address more than 183 seconds later. Since the day I found that almost all of my feed traffic is generated by my own scheduler rather than by readers, I have been wary of assuming "the same reader comes back to the same page" on this site; I haven't measured how often that return happens, and until I do I won't put a number on this cost.

If it were up to me I would order it like this: the second path first, because it is a small configuration change and it either makes a dead directive work or lets me delete it. I would not touch the first path without measuring. Choosing the third is legitimate too — as long as you know you chose it. What I had been doing for four months wasn't choosing. It was not noticing.

How to check this on your own stack

These steps will produce the same picture on your setup. From the innermost layer outwards:

# 1. What does the application say? (straight to the origin)
curl -sI http://127.0.0.1:<port>/<path> | grep -iE 'cache-control|last-modified|etag'

# 2. Is the body really stable? (two renders, same digest?)
curl -s http://127.0.0.1:<port>/<path> | md5sum
curl -s http://127.0.0.1:<port>/<path> | md5sum

# 3. What does the reverse proxy add or strip? (bypassing the CDN)
curl -sI -k -H 'Host: <domain>' https://127.0.0.1/<path> | grep -iE 'cache-control|last-modified|etag'

# 4. Does the validator hold? (conditional request through the edge)
LM=$(curl -sI https://<domain>/<path> | awk 'BEGIN{IGNORECASE=1}/^last-modified:/{sub(/^[^:]*: /,"");print}' | tr -d '\r')
curl -s -o /dev/null -w '%{http_code} %{size_download}\n' -H "If-Modified-Since: $LM" https://<domain>/<path>

# 5. How long does it hold? Wait out the TTL plus a few seconds and repeat step 4.

# 6. After a fix, as a watchdog: do we see UPDATING at the refresh moment?
curl -sI https://<domain>/<path> | grep -i 'cf-cache-status'
Enter fullscreen mode Exit fullscreen mode

If the answer to step four is 304 0, you are fine at that moment. Don't skip step five: a single measurement only shows you the inside of the window. The only reason I saw those 183 seconds is that I waited after the first measurement and repeated the same request. And don't treat step six as a one-off — this behaviour can quietly revert with a Cache Rules edit, a plan change, or me touching that proxy_hide_header line in future, with every layer still looking green.

A note: don't send the conditional request with both If-Modified-Since and If-None-Match. Per RFC 9110 a recipient must ignore If-Modified-Since when If-None-Match is present; send both and you won't know which mechanism you measured.

Conclusion

Cache validation is a handshake: both sides have to point at the same thing. In my chain one of the sides was describing not what it held but when it came into its hands. The standard permits this and spells out step by step what to do when there is no validator; Cloudflare has documented it too. Nobody misled me — I left a gap in the middle of my stack, and the gap filled with what the rules prescribe.

The lesson I take from this: if a piece of data isn't produced by the layer best suited to produce it, the nearest layer substitutes something predictable but wrong. Here that something was a timestamp. The gap makes no noise, every layer looks green, and the cost is simply that someone at the edge downloads an extra 64 KiB without ever knowing why.

That is also why I hadn't measured this for four months: no indicator was burning red. To decide to measure, I needed not a failure but a question.

Official Sources

Top comments (0)