The classic problem here is if you do that thing where user agents that send "accept: text/html" get HTML, while user agents that don't get JSON or some other format.
This used to be impossible to deploy being Cloudflare caching, because they ignored the Vary header on anything other than images - so you risked caching the JSON version and then serving it up to someone who was expecting HTML.
(Independent of the Cloudflare feature I ended up deciding never to use that pattern, because I prefer having URL that predictably returns HTML or JSON - I add a .json suffix to my apps to serve JSON instead.)
Frankly, the supposed variability of the Accept header never really sat all that well with me; in practice I much prefer working with explicitly versioned endpoints — one of the most infuriating things is having to hardcode "Accept: text/x-myorgname-custom-json-blob-v4" because omitting it would produce "406 Not Acceptable". Bonus points if that's the only Accept header the service would ever accept in all of three years of it working before being decommissioned. Double bonus points if v5 would be introduced behind a separate URI anyway (and it, too, would require precisely "Accept: text/x-myorgname-custom-json-blob-v5" and nothing else).
I always thought Accept (and Accept-Language) headers are quite neat. Surely I can try article.es.md first, get a 404, try article.en.md, get a 404, try article.es.txt, article.en.html and so on, until I find the thing I'm looking for. But Accept let's me say "I'll take any one of these, preferably in this order". This is especially useful now when agents are reading the web and they don't really need all of our HTML code - just the text.
If you're versioning the whole API though, I agree it's best behind a a /v2/ prefix.
REST is all about MIME types and Accept/Content-Type. So there goes REST.
TFA makes me think that your argument is stronger than I would have thought yesterday, though I still prefer to have Accept/Content-Type negotiation. Sibling's comment about negotiation is on-point.
And I think there is Content-Location header as well?.. My point is, it's possible to divorce caching of the server's response that choses the best available representation from caching of the actual content, and if you can't send Vary with the first response, then "Cache-Control: private, max-age=0" will still probably won't kill your redirection-making server. Or there is the Negotiate/Alternates + 300 response code mechanism as well!
They can't do that because a CMS under Cloudflare needs to be configured to bust the Cloudflare cache when content is edited or the user will see cached content after they edit a page.
That might be the default configuration if your CMS doesn't return any cache-control headers. But Cloudflare definitely [0] supports must-revalidate and etag or last-modified, which is also probably supported by whatever CMS you're using. HTTP conditional requests are very old and very widely supported.
[0] Okay, my claim is only definite up to my memory of using Cloudflare for a fairly high-traffic circa 2019-2022. I haven't used Cloudflare after that point, but a quick search of their docs shows support
Right. But if the cache MUST be revalidated every time, it's the same thing as being disabled.
The point of a reverse proxy is to reduce load on the server rendering the page. If you GET, the page is rendered. So in order for etag/last-modified to be effective, the CMS must implement HEAD. If the CMS doesn't implement HEAD or implements it in a way that ends up doing all the work of GET anyway, you just end up with more load than just doing a GET every time.
The funny thing is that the way HTTP is designed the simplest PHP script can do this correctly, because the server must send the headers first, so a PHP script can NOT do the work to generate the HTML until it has sent the correct headers, as they won't be able to change the headers after they start sending the body. In theory, the process could just shut down the instant the first body byte starts being sent and save work in a HEAD request. The problem is that pretty much everything works in a more "clever" way: you have some sort of template engine that generates the whole HTML, a view method that makes the DB calls, etc., and only after all the work is done the HTTP headers are sent. Because this method allows the backend to add/change/remove headers at any point during the process, not just at the start.
Essentially the problem is that CF doesn't know what your server is running, and even an s-maxage=60 is going to look like a bug from the user's perspective if they go to /post?id=123, navigate to /edit-post?id=123, edit the post, go back to /post?id=123 and see the post unchanged.
What are you referring to with the origin needing to support HEAD requests? For HTTP conditional requests your origin just needs to support GET with if-none-match or if-modified-since. Then it responds with a 304 with no body if the cached version is still valid. The origin should, of course, be able to determine cache validity much more cheaply than rendering the whole page, which is usually as simple as checking a single timestamp in the database.
And, of course, you can still use cache-control to permit some duration of staleness, so that you don’t literally revalidate on every request.
Wow this brings back memories. Over twenty years ago I added Vary support to various mod_cache submodules for Apache 2.0, and it was way too high a pain to reward ratio. It just uncovered so many user agent bugs and crazy backends. I remember arguments about being able to cache variable based on non-literal virtual headers (e.g. a geo location header), and a crazy request to Vary based on "Date:" ... which makes absolutely no sense. The whole thing was just too clever for its own good. It's no surprise that language selection ended up in URLs (/en-US/..) rather than "Vary: Accept-Lang" features.
I don't understand why en-US and en-GB should be treated as the same. Wouldn't this result in American visitors seeing words like colour since the wrong English page would be served.
Most sites don't have different variants of English. They just pick one and go with it.
You do need a UK and US version of your site if you sell things in both countries, but that's usually a region selector where you don't use the HTTP language system. If you did then anyone with their language set to en-US would see the American site even if they live somewhere else.
>to en-US would see the American site even if they live somewhere else.
Instead we have google that would serve the pages based off the local ip (and switch any time possible) which is a "horrific" experience travelling through Europe. You get a different language every day. Totally ignoring the accept-language header.
It's even funnier as the roaming would result in having the host mobile network IP regardless where a person resides - still ignoring the lang header, of course.
One major issue with Vary is that it makes cache coalescing very complicated. This is because you can't know whether two requests will share a response until you get that response. So for subsequent requests you need to decide if you should block it hoping that the response will satisfy it or if you should send it through pessimistically, possibly triggering a thundering herd effect on every cache rotation.
I'm not sure if it is possible to solve this nicely in a generic way, but it does make it a bit ugly.
This is not caused by Vary but by the app being so.. variable.
If you segregate content-type/language by end-point then the variability goes away. But now you have a plethora of end-points. The complexity can get moved around, and in this particular case the complexity for the caching layer can be removed, but the complexity isn't gone.
32 comments
[ 0.28 ms ] story [ 39.7 ms ] threadThe classic problem here is if you do that thing where user agents that send "accept: text/html" get HTML, while user agents that don't get JSON or some other format.
This used to be impossible to deploy being Cloudflare caching, because they ignored the Vary header on anything other than images - so you risked caching the JSON version and then serving it up to someone who was expecting HTML.
(Independent of the Cloudflare feature I ended up deciding never to use that pattern, because I prefer having URL that predictably returns HTML or JSON - I add a .json suffix to my apps to serve JSON instead.)
If you're versioning the whole API though, I agree it's best behind a a /v2/ prefix.
TFA makes me think that your argument is stronger than I would have thought yesterday, though I still prefer to have Accept/Content-Type negotiation. Sibling's comment about negotiation is on-point.
I actually assumed when I started using Cloudflare that it did have vary support and it led to a serious bug in my sass app at the time.
Actual real content negotiation in 2026. Never thought I'd live to see the day.
"If one response omits it, Cloudflare could cache that response without the variance needed to keep it isolated."[2]
Will that non-Vary cache object front-run any Vary-segmented cache objects?
If so, probably worth adding a snippet rule to ensure every response has a Vary header?
1: https://developers.cloudflare.com/cache/concepts/vary/#how-v...
2: https://blog.cloudflare.com/vary-support/#how-a-response-mov...
Wow, sometimes I think it's amazing the web works at all!
[0] Okay, my claim is only definite up to my memory of using Cloudflare for a fairly high-traffic circa 2019-2022. I haven't used Cloudflare after that point, but a quick search of their docs shows support
The point of a reverse proxy is to reduce load on the server rendering the page. If you GET, the page is rendered. So in order for etag/last-modified to be effective, the CMS must implement HEAD. If the CMS doesn't implement HEAD or implements it in a way that ends up doing all the work of GET anyway, you just end up with more load than just doing a GET every time.
The funny thing is that the way HTTP is designed the simplest PHP script can do this correctly, because the server must send the headers first, so a PHP script can NOT do the work to generate the HTML until it has sent the correct headers, as they won't be able to change the headers after they start sending the body. In theory, the process could just shut down the instant the first body byte starts being sent and save work in a HEAD request. The problem is that pretty much everything works in a more "clever" way: you have some sort of template engine that generates the whole HTML, a view method that makes the DB calls, etc., and only after all the work is done the HTTP headers are sent. Because this method allows the backend to add/change/remove headers at any point during the process, not just at the start.
Essentially the problem is that CF doesn't know what your server is running, and even an s-maxage=60 is going to look like a bug from the user's perspective if they go to /post?id=123, navigate to /edit-post?id=123, edit the post, go back to /post?id=123 and see the post unchanged.
No, you just implement GET properly so that nothing gets "rendered" for 304 Not Modified responses.
And, of course, you can still use cache-control to permit some duration of staleness, so that you don’t literally revalidate on every request.
But we can't cache those dynamic pages since they vary based on accept language. Maybe with this we can
https://blog.cloudflare.com/enterprise-grade-features-for-al...
Like Prefetch: https://developers.cloudflare.com/speed/optimization/content...
You do need a UK and US version of your site if you sell things in both countries, but that's usually a region selector where you don't use the HTTP language system. If you did then anyone with their language set to en-US would see the American site even if they live somewhere else.
Instead we have google that would serve the pages based off the local ip (and switch any time possible) which is a "horrific" experience travelling through Europe. You get a different language every day. Totally ignoring the accept-language header.
It's even funnier as the roaming would result in having the host mobile network IP regardless where a person resides - still ignoring the lang header, of course.
I’d say that it forces you to maybe parse and understand the headers that vary header.
Accept-Language is supposed to be parsed and matched according to RFC4647 in one of 3 ways. That part is rather complex, but it’s not Vary’s fault.
I'm not sure if it is possible to solve this nicely in a generic way, but it does make it a bit ugly.
If you segregate content-type/language by end-point then the variability goes away. But now you have a plethora of end-points. The complexity can get moved around, and in this particular case the complexity for the caching layer can be removed, but the complexity isn't gone.