The short version. I spent two days establishing that my web server refused to vary its cache by the Accept header. I wrote that conclusion into my own configuration, into my notes, and into a published article. It was wrong three times, in three different ways, and I only found out by reading the module’s source.
The server keys its cache on request headers perfectly well. It learns which ones from the Vary header of a cacheable response. My site never sent one — the only response that carries Vary: Accept is marked uncacheable three lines earlier, so it never reaches the code that would have recorded it.
And the log line I built an entire theory on top of does not say what I thought it said. needn’t to add means “you do not need to add that, I already know it”. I read it as “I refuse”.
What I was trying to do
Serve two formats from one URL. A browser asks for text/html and gets HTML; an agent sends Accept: text/markdown and gets Markdown. Same address, content negotiated by header. It is the oldest idea in HTTP and it collapses the moment a cache sits in front of it, because a cache that stores one copy per URL will hand the second caller whatever the first caller happened to get.
The fix for that is supposed to be the Vary header. I put a directive in my virtual host that looked like it did the same job, measured, concluded it did nothing, and turned the feature off. The note I wrote at the time now carries a correction box, which is what sent me back here.
The three explanations, in order
Each of these was written down somewhere real — a config comment, a note, a published article — and each was believed at the time.
| When | What I wrote | Why it was wrong |
|---|---|---|
| 7 Sep | “The cache key cannot vary by request header in this version.” | It can. That is what the whole mechanism is for. |
| 11 Sep, morning | “It can, but the module deletes standard headers on purpose and logs that it did.” | It deletes them from one specific list, because standard headers do not need to be in that list. |
| 11 Sep, afternoon | The mechanism reads the response’s Vary; my site never sent one on a cacheable response. | — verified in the source, below. |
The second one is the interesting failure. It was closer to the code than the first, it quoted a real log line, and it was still wrong — because I had read the log line and not the function that emits it.
The log line, and what it actually means
The server had been printing this since the reload that introduced my directive. Six times in the live log, and the rotated logs before it contain none, so the trail starts exactly there:
2026-09-07 21:47:43 [INFO] [CACHE] needn't to add "Accept" to config which is standard request header.
2026-09-09 13:48:19 [INFO] [CACHE] needn't to add "Accept" to config which is standard request header.
2026-09-09 14:12:53 [INFO] [CACHE] needn't to add "Accept" to config which is standard request header.
2026-09-09 14:29:37 [INFO] [CACHE] needn't to add "Accept" to config which is standard request header.
2026-09-09 14:29:48 [INFO] [CACHE] needn't to add "Accept" to config which is standard request header.
2026-09-09 15:18:02 [INFO] [CACHE] needn't to add "Accept" to config which is standard request header.When I finally found it, I read it as a rejection: you asked me to vary on Accept, and I will not, because it is a standard header. That reading produced explanation number two, and it is the one I published to my own config file with some confidence.
Read the English again. “Needn’t to add” is not a refusal. It is the server telling me my line was redundant. The directive I had written registers header names the server does not already know. Accept is one it has known since it was compiled.
What the source says
This is OpenLiteSpeed, which is open source, so the question has an answer rather than an opinion. The server here reports LiteSpeed/1.9.1 Open, and the four passages below are from the cache module at the matching tag, downloaded and checked rather than remembered.
Where the list is consulted, and where it is not
static int getVaryKeyIndex(CacheConfig *pConfig, const char *key, int len)
{
int id = HttpHeader::getIndex(key, len);
if (id > HttpHeader::H_TRANSFER_ENCODING)
{
id = -1; //ERROR case
... walk the configured vary list ...
}
return id;
}A standard header returns its index on the first line and never reaches the list at all. Accept is H_ACCEPT = 0 in the header enum. The list exists for names above H_TRANSFER_ENCODING — that is, the ones the server has no index for, which is exactly what the log line was telling me.
What actually turns varying on
int count = g_api->get_resp_header(session, LSI_RSPHDR_VARY, NULL, 0, iov, 1);LSI_RSPHDR_VARY. The response header. The cache reads the Vary that the origin sent, splits it on commas, resolves each name to an index, and sets a bit per index. Nothing about this is configured in the virtual host. The application decides, per response, by saying so in a header.
And where the bit is spent
for (int id = 0; id < HttpHeader::H_TRANSFER_ENCODING; ++id)
{
if (flag & 1 << id)
{
buf = g_api->get_req_header_by_id(session, id, &len);
...append to the cache key...
}
}The loop starts at zero, so it covers H_ACCEPT. If the bit is set, the value of that request header goes into the cache key and the two formats get separate entries, which is precisely the behaviour I had spent two days declaring impossible.
Why my measurement proved nothing
Here is the test I ran, against the origin directly so no CDN was involved. It looks conclusive:
curl -sk -o /dev/null -D - https://…/terms/ → x-litespeed-cache: miss
curl -sk -o /dev/null -D - https://…/terms/ → x-litespeed-cache: hit
curl -sk -D - -H 'Accept: text/markdown' https://…/terms/ → x-litespeed-cache: hit
curl -sk -D - -H 'Accept: application/x-nada' https://…/terms/ → x-litespeed-cache: hitDifferent Accept, same cached copy. Case closed, except for the one header I never looked at. The HTML response carries no Vary at all — it sends content-type and the cache status and nothing else. With no Vary on the response, the flag is zero, the key gets nothing appended, and every Accept in the world reads the same entry.
I had warmed the cache with a response that never claimed to vary, then proved that it did not vary. A negative result with no positive control is not a finding, it is a restatement of the setup. The same discipline applied to a cache-buster measuring a URL nobody else requests would have caught this two days earlier.
Where the actual fault was: my own code
One response on this site does send the header. It is generated by a small plugin of mine, and here it is, unedited:
do_action( 'litespeed_control_set_nocache', 'markdown negotiation' );
status_header( 200 );
header( 'Content-Type: text/markdown; charset=utf-8' );
header( 'X-Content-Type-Options: nosniff' );
header( 'Vary: Accept' );
header( 'X-Robots-Tag: noindex' );
header( 'Cache-Control: private, no-store, max-age=0' );
header( 'X-LiteSpeed-Cache-Control: no-cache' );The first line takes the response out of the cache. The fifth line tells the cache how to key it. A response that is never stored never runs the code that reads its Vary, so the instruction was addressed to something that had already stopped listening.
I wrote both of those lines, months apart, each one correct for the problem in front of me at the time. Together they guarantee that the server can never learn what I was insisting it refused to learn.

Vary. My site was on the bottom one and I was reading the middle one.What I did not test, and then did the next day
Correction, 13 September 2026. This section used to be headed “What I did not test, and will not”, and gave a reason: that measuring the positive case meant changing the plugin and reloading the server, which takes four sites down for a moment. The reason was wrong. A response header comes from PHP and needs no reload at all — the test took a fifteen-line plugin and about a minute, and I used an inflated cost to justify not running an experiment. That is the part worth keeping on the page.
Here is the positive case, measured. With Vary: Accept added to the cacheable HTML response, on one URL, changing only the header:
Accept: text/html -> x-litespeed-cache: miss
Accept: text/html -> x-litespeed-cache: hit
Accept: text/markdown -> x-litespeed-cache: MISS <- its own entry
Accept: text/markdown -> x-litespeed-cache: hit
Accept: application/json -> x-litespeed-cache: MISS <- another one
Accept: text/html -> x-litespeed-cache: hit <- back to the firstEvery distinct Accept value gets its own cache entry. The mechanism described above is exactly what happens, and the rest of this note stands as written.
One measurement on the way there deserves recording because it looked like a refutation and was not. This site’s HTML response already carried Vary: Accept-Encoding, so I tried the cheap test first — three different encodings against the same URL — and got a hit for all of them. That looked like the mechanism failing. It is not: compression variants are handled separately, so Accept-Encoding is the one header in a Vary that does not demonstrate this. The test had to be run with a header the cache treats normally.
What changed as a result: the negotiation this note is about is now switched on, because the reason it was off — that it only worked between a purge and the first human visit — was a consequence of the missing Vary, and no longer applies. A later note covers what that took and what it cost.
The decision was right. The reason was not
Switching the negotiation off was correct at the time, for a reason I had written down wrongly for four days. It is back on as of 13 September, once the missing piece below was found and added; the paragraphs that follow explain why it had to be off until then.
There is a second cache in front of the first one. Cloudflare does not honour Vary either, and unlike the origin there is no directive that makes it. That was measured on 26 August, written into the same plugin’s comments, and sat there while I was busy diagnosing the layer underneath: a request with Accept: text/markdown came back MISS, and the next browser request to the same URL got a HIT serving Markdown. A visitor would have been shown plain text.
Fixing the origin would not have fixed the feature. It would have moved the failure up one layer and made it harder to see — which is the worst possible outcome of a correct fix applied for a wrong reason.
What works instead is a separate URL per format. This site serves /terms/ and /terms.md, and no cache anywhere has to be persuaded of anything, because the two documents have two names. It is less elegant than negotiation and it has the significant advantage of being true.
What the standard requires, and who actually does it
None of this is exotic. RFC 9111, section 4.1, is titled “Calculating Cache Keys with the Vary Header Field” and it is not advisory: a cache “MUST NOT use that stored response without revalidation unless all the presented request header fields nominated by that Vary field value match those fields in the original request”.
So the origin cache here does the standard thing, once it is told. The interesting divergence is one layer up.
| Layer | Honours Vary on HTML | How it is told |
|---|---|---|
| The standard (RFC 9111 §4.1) | Required, with MUST NOT | The response says so |
| The origin cache here | Yes, per the source above | The response says so, if the response is cacheable |
| The CDN in front of it | No, measured 26 August | No directive available |
The bottom row is why the feature stays off. A specification that says MUST is a description of correct behaviour, not a guarantee about the intermediary somebody put in front of you.
What the wrong explanation cost
Not much in money and quite a lot in signal. Believing the mechanism was impossible, I switched the negotiation off. On a public agent-readiness checker that treats format negotiation as a gate, this site fell from Level 5 to Level 2 — three steps — while the underlying score moved only from 80 to 73. I wrote about that scale in its own note, and at the time I described the drop as the price of a correct decision.
It was the price of a correct decision. It was also two days of measurement, two server reloads, three written explanations and a published article that needed a correction, all of which a single reading of forty lines of source would have replaced.
The Vary header, Accept and cache keys: frequently asked questions
Does LiteSpeed support Vary: Accept?
Yes, in the version here, and this was measured on 13 September rather than only read in the source: the cache module reads the Vary header from a cacheable response, resolves each named header to an index, and folds the matching request header’s value into the cache key. Every distinct Accept value then gets its own entry. Standard headers like Accept need no configuration for this.
What is reqHeaderVary for, then?
Registering header names the server has no built-in index for — your own X-Something. Adding a standard header to it is harmless and unnecessary, and the module logs that it is unnecessary. It is not the switch that turns varying on.
Why did my Vary header have no effect?
Check whether the response carrying it is cacheable. If the same code path marks it no-cache, or the origin sends no Vary on the response that actually gets stored, the cache never learns the page varies. The header has to be on the response that enters the cache, not on a different one.
Is a log line at INFO level enough to diagnose this?
It pointed at the right subsystem and I still drew the wrong conclusion from it, so: no, not on its own. The line said the directive was unnecessary; I read that as the directive being rejected. Before building a theory on a log message, find the function that emits it.
Should I use content negotiation to serve Markdown to agents?
Not through a CDN that ignores Vary, which is most of them. Two URLs cost nothing and cannot be got wrong by an intermediary. Negotiation on one URL is the more elegant design and it depends on every cache between you and the reader behaving, which is not a thing you can verify once and stop checking.
How do I tell whether my cache is varying at all?
Warm it with one header value, request the same URL with a different value, and read the cache status. A hit on the second request means one shared entry. A miss means it is keying on that header. Then check that the response you warmed it with actually carried the Vary you think it did — that is the step I skipped.
The general shape of this
Three explanations, and the first two were confident, specific, and written into places where they would be believed later: a config comment, a private note, a published article. The thing that finally settled it was not a better measurement. It was reading forty lines of the program that was doing the work.
The failure mode worth naming is the second explanation rather than the first. The first was ignorance. The second was a real log line, correctly quoted, read in the wrong sense, and dressed in enough detail to look like a diagnosis. It was more persuasive than the truth and it was wrong, and detail is what made it dangerous.
The same Accept header returns one layer out, at the edge, where a CDN rule that appears to ask one question is really asking another.
Everything here is checkable: the source is public and versioned, the log lines are quoted with their timestamps, and the measurement that misled me is printed above so you can see exactly what it was missing. The earlier note now carries a correction rather than a quiet edit, because a site that sells verifiable claims does not get to rewrite the ones that turned out wrong. If you want this kind of reading done on a stack of yours, that is the work, and it starts with the free check.


