The cache is in place, the configuration has been reviewed, the reverse proxy is running - and the load on the application stays exactly where it was. This case is more common than a missing cache and more awkward, because formally everything is correct. The cause is rarely in the cache configuration itself. It sits in the Vary header of the response, in a session cookie that is re-issued on every response, and in the parameters a campaign appends to the URL. Each of these splits one address into variants, and every variant has to pass through the application once before it can ever be served from storage. This article shows how to measure the number of variants per URL, which Vary values can be dropped, how campaign parameters fall out of the key, and when a personalised component is better delivered as its own fragment.
Key takeaways
- A cache key is not the URL alone. If a stored response carries a Vary header, the cache may only use it without revalidation when all header fields named there match those of the original request (RFC 9111). Every additional name in the Vary value multiplies the entries.
- A star in the Vary value is not a fine adjustment, it is an off switch: a stored response whose Vary value contains a star never matches (RFC 9111).
- A Set-Cookie does not prevent storage. The specification points out explicitly that a cacheable response carrying Set-Cookie can be used to satisfy further requests (RFC 9111) - which is why widely used proxies refuse the hit on their own as soon as a cookie is involved (Varnish).
- For scale: a website sets a median of 9 cookies (Web Almanac 2025). With Cookie in the Vary value, every combination of those values produces its own entry - in practice one per visitor.
- What gets measured is not the hit rate of the whole domain but the hit rate per template. A home page that almost always hits and a search results page that never does add up to a number that explains nothing.
- Anything personalised belongs in its own fragment with its own lifetime: in the example from the Symfony documentation the full page cache stays valid for 600 seconds while the cache of the dynamic component lasts only 60 seconds (Symfony).
Why a finished cache still misses
Building a cache layer is manageable work: put a proxy in front, set lifetimes, add exceptions for cart and login. How those layers interlock is covered in the article on caching strategies with Varnish and Redis. Debugging a layer that already exists is a different job, and it starts in a place few people look at: the key a response is stored under.
The specification describes the comparison unambiguously. If a stored response carries a Vary header, the cache may only use it without revalidation when all header fields named there match between the new request and the original one (RFC 9111). If one of those headers is absent from the new request, it only matches when it was absent from the old one too. One address therefore turns into as many entries as there are combinations of the named values - and each of those entries has to be produced once before it can be delivered for the first time.
At the end of that scale sits a value that ends every further consideration: a stored response whose Vary value contains a star always fails to match (RFC 9111). Finding that value in a response does not mean fine tuning lies ahead; it means the cache is switched off while still costing money - the proxy asks the origin every single time and keeps filling storage with entries nobody will ever read.
Three questions before any change
Vary: every exception multiplies the entries
The header is less widespread than one might assume: in an analysis of mobile responses, 46.25 percent carried a Vary header at all (Web Almanac 2021). Among those that do, a single harmless value dominates. The survey lists Accept-Encoding at 90.3 percent, followed by User-Agent at 10.9 percent, Origin at 10.1 percent and Accept at 4.8 percent (Web Almanac 2021). The order is telling, because the runner-up is the most expensive value on the whole list.
Accept-Encoding is the normal case and costs next to nothing: it produces two or three variants, depending on which compression formats are served. User-Agent is the opposite. The documentation of a widely used proxy puts it plainly: a single patch level of the same browser already generates at least 10 different User-Agent headers purely because the operating systems differ (Varnish). Without prior normalisation into a handful of classes, that value is unusable in a shared cache.
The most expensive value in practice is not on that list, because a broad survey rarely catches it: Cookie. A cookie header is not a choice among a few options but a string that differs per visitor. The browser documentation is correspondingly clear: for applications that use cookies to keep personalised content away from other people, specify Cache-Control: private instead of naming a cookie in Vary (MDN Web Docs). The difference is fundamental - private prevents storage in a shared cache, while Vary: Cookie permits it and merely makes it useless.
| Value in the Vary header | Variants per URL | Assessment |
|---|---|---|
| Accept-Encoding | two to three | necessary as long as content is served compressed |
| Accept-Language | one per offered language | acceptable when the language is not already in the path |
| User-Agent | effectively unlimited | only after normalisation into a few device classes |
| Cookie | one per value combination | almost always wrong; set private instead |
| Origin | one per calling origin | useful on APIs, not on documents |
| A star | none that can be used | the response is stored and never found again |
The same pattern repeats with newer negotiation headers. The 2025 analysis of delivery networks puts Client Hints at 4 percent of requests and attributes the low adoption partly to the cache key exploding at the edge when the values are taken over without bucketing (Web Almanac 2025). Anyone serving through a delivery network with edge caching pays for every variant a second time there - once per location.
The counter-check is simple and rarely done: look at the Vary value of a response and estimate, for every name in it, how many distinct values occur in real traffic. The product of those numbers is the number of entries created for a single address. If it exceeds the number of requests per lifetime, that address can never reach a meaningful hit rate, no matter how the cache is tuned.
The session cookie on every response
The second route into fragmentation is quieter still, because it works without Vary. Many applications start a session on every request - enabled by default in the framework, added by a module, brought along by an extension. Every response then carries a Set-Cookie, including the category page that looks identical for every visitor. Where that session is genuinely needed and what else it costs is covered in the article on the session lock in the shopping cart.
The specification itself does not stop any of this: Set-Cookie does not inhibit caching, and a cacheable response carrying a Set-Cookie header can be - and often is - used to satisfy subsequent requests (RFC 9111). That is not only a speed problem; it is the reason why the defaults of widely used proxies are deliberately cautious. In the default configuration no object carrying a Set-Cookie from the backend is stored, and as soon as the client sends a Cookie header, the request bypasses the cache and goes straight to the backend (Varnish). Together, those two rules are enough to keep a perfectly configured cache from ever answering in production.
This is no edge case in the wild. In one measurement, 36.0 percent of cacheable responses on mobile carried a Set-Cookie, and 35.6 percent on desktop; at the same time only around 5 percent of pages combined that with the private directive (Web Almanac 2021). Every one of those responses is either not storable at all or sits in a shared cache with somebody else's session attached.
# Before: the application starts a session on every request
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Set-Cookie: SESSID=7f3c9a...; Path=/; HttpOnly; SameSite=Lax
Cache-Control: public, s-maxage=600
Vary: Cookie, Accept-Encoding
# Result: one entry per cookie value, so effectively one per visitor.
# The first call of every variant goes straight through to the app.
# After: sessions only on the routes that need them
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Cache-Control: public, s-maxage=600, stale-while-revalidate=60
Vary: Accept-Encoding
# Where single fields really are personal, the qualified form remains:
# the shared cache drops the named field and stores the rest.
Cache-Control: s-maxage=600, private="Set-Cookie"
The way out is not to hide the cookie but to stop creating it where it has no job. Three steps in this order: start the session only on the routes that need it; strip the cookie from the stored response, but exclusively on routes that are demonstrably identical for everyone; and where single fields really are personal, use the qualified form. The specification allows it explicitly: a shared cache must not store the listed header fields but may store the remainder of the message (RFC 9111).
Vary on Cookie
Creates one entry per value combination. Because a cookie header differs per visitor, the number of entries ends up being the number of visitors.
Set-Cookie on every response
Prevents storage outright in many proxies - and where it does not, somebody else's session travels into the shared cache.
Campaign parameters
Every identifier in the URL creates its own entry, even though the delivered document is identical character for character.
Consent state in the response
If the state is rendered into the document on the server, every response is personal. A value that only takes effect in the browser is the better shape.
Language negotiated twice
With the language in the path and additionally in the Vary value, the number of entries doubles without any gain in correctness.
Device class without normalisation
Distinguishing by the full browser identifier means storing a separate copy for every operating system build.
The order that has proven itself
Measuring the hit rate per template
Every change starts from a baseline, and not from a single one. A hit rate across the whole domain averages the home page, which almost always hits, with the search results page, which never does. Which templates exist at all and how to group an inventory by them is covered in template performance beyond the home page. For cache diagnosis the same grouping is what gives a finding an addressee in the first place.
The source is the access log of the proxy, not a tool in the browser. Three fields are enough: the hit status, the template and the URL without parameters. The template is set by the application as a response header - guessing it from the path turns out wrong as soon as special pages exist. What a miss actually costs shows up next to it as the server share per response; carrying that value into the browser is the job of Server-Timing.
# The proxy writes hit status and template into a line of its own:
# log_format cache '$time_iso8601 $upstream_cache_status '
# '$sent_http_x_template $uri';
# Requests and hits per template, sorted by request count
awk '{ n[$3]++; if ($2 == "HIT") h[$3]++ }
END { for (t in n)
printf "%-18s %8d requests %6.1f hits per hundred\n",
t, n[t], 100 * h[t] / n[t] }' \
/var/log/nginx/cache.log | sort -k2 -nr
# Which headers produce several entries for ONE address?
varnishlog -g request -q 'ReqURL eq "/category/running-shoes"' \
-i ReqHeader -I 'Accept-Encoding|Accept-Language|Cookie' \
| sort | uniq -c | sort -nr | head -20
# Ceiling for any hit rate: share of URLs requested exactly once
awk '{ print $4 }' /var/log/nginx/cache.log | sort | uniq -c \
| awk '{ total++; if ($1 == 1) once++ }
END { print once, "of", total, "URLs requested only once" }'
- Log the hit status per response instead of taking the aggregate hit count from the proxy statistics.
- Keep the template as a field of its own, set by the application - category, detail, search, content page, home page.
- Collect the number of stored variants per URL; it is the actual key figure of this diagnosis.
- Determine the share of URLs requested exactly once in the period - it is the ceiling for any reachable hit rate.
- Put the server share per response next to it so the cost of a miss can be quantified.
- Run the measurement for at least a full week so weekend, dispatch day and campaign launch are included.
The number that is almost always missing
Campaign parameters and the key of a URL
The third route into fragmentation runs through the address itself. For the visitor and for the application, /category/running-shoes is the same page as /category/running-shoes?utm_source=newsletter. For the cache they are two entries, and every further identifier - click id of an ad network, dispatch number of a newsletter, test group of an experiment - adds another. A single newsletter send can produce more entries in one morning than a whole week of organic traffic.
The rebuild is quickly described and delicate in detail: an allow list of the parameters that genuinely change the delivered content goes into the key, everything else is dropped before the lookup. The direction of that list is what matters. Maintaining a block list instead means the next campaign tool puts a new identifier into the key and nobody notices until the load moves. That this multiplication is not specific to one system is shown by the cache hash in a editorial system, which is built from parameters on exactly the same principle.
# Key built from path plus a fixed allow list of parameters.
# Everything not named - campaign, click id, test group - is dropped
# before the lookup.
proxy_cache_key "$scheme$host$uri|p=$arg_page|o=$arg_sort|f=$arg_filter";
# Keep Vary down to what is needed. The application may still send
# whatever it likes; the shortened form is what gets stored.
proxy_hide_header Vary;
add_header Vary "Accept-Encoding" always;
# Drop the session cookie from the stored response - ONLY on routes
# that are demonstrably identical for every visitor.
location ^~ /category/ {
proxy_ignore_headers Set-Cookie;
proxy_hide_header Set-Cookie;
proxy_cache pages;
proxy_cache_valid 200 10m;
# Log hit status and template so the measurement has a source
add_header X-Cache-Status $upstream_cache_status always;
access_log /var/log/nginx/cache.log cache;
}
Two side effects belong in the acceptance test. First: if parameters are removed from the key while the application is still called with them, the cache may serve a response that was produced for a different identifier - harmless for campaign tags, critical for anything that steers the content. Second: stripping Set-Cookie from stored responses has to be limited to paths that are demonstrably identical for every visitor. In a shop that usually means category, detail and content pages, not the cart and not the checkout - a boundary that is worked through on a concrete system in the article on Shopware performance.
Personalised components as their own fragment
That leaves the case that triggers the whole discussion: something on the page looks different for every visitor - the cart counter, the greeting by name, the last item viewed, the state of consent. As long as that component sits inside the same document, the whole document is personal. A full page cache can then only get it wrong: either it does not store the page at all, or it hands one person somebody else's shopping cart.
Splitting it solves the problem by putting two lifetimes side by side. The document gets a long lifetime and is identical for everyone, the personal part gets its own request and a short one. The Symfony documentation shows the arithmetic in an example: with Edge Side Includes the full page cache stays valid for 600 seconds while the cache of the news component lasts only 60 seconds (Symfony). The ratio is the point, not the absolute values - the page lives ten times as long as its most volatile part.
<!-- Document: long lifetime, identical for every visitor
Response header: Cache-Control: public, s-maxage=600 -->
<header class="head">
<a class="brand" href="/">Example shop</a>
<!-- Personal part as a separate call with its own lifetime
Response header of the fragment: Cache-Control: private, no-store -->
<esi:include src="/fragment/cart" onerror="continue"/>
</header>
<!-- Without an ESI-capable proxy, the same thing in the browser. The
space is reserved so nothing moves when the part arrives. -->
<div id="cart" style="min-height:28px"></div>
<script type="module">
const response = await fetch('/fragment/cart', { credentials: 'same-origin' });
if (response.ok) {
document.getElementById('cart').innerHTML = await response.text();
}
</script>
Without a proxy that understands fragments, the same result is available in the browser: the document is served statically and the personal part arrives as a small follow-up request. The cost is one additional request and a possible layout shift when the space is not reserved. With consent layers that is the classic mistake - how to avoid it is covered in the article on consent banners and the largest contentful paint. And where the personal part collects measurement data itself, the limits from performance measurement without consent apply.
Order of work and acceptance
The diagnosis ends with a list that can be worked through and a number the result can later be recognised by. Both belong in the same week: without a baseline taken before the first line is changed, what remains afterwards is an assumption instead of evidence. If the remaining load sits mostly in the backend, the road continues through server optimisation; if it sits in the number of variants, the work stays on the key.
- Add hit status, template and the URL without parameters to the access log and collect a full week.
- Evaluate requests, hits and variants per URL for each template and name the three templates with the largest volume of misses.
- Trim the Vary value of those templates to what is needed and roll out each removal separately so the effect stays attributable.
- Start sessions only where they are needed and strip Set-Cookie from the stored response on the approved paths.
- Take campaign parameters out of the key via an allow list and maintain that list wherever the campaigns are maintained.
- Split out personalised components and give the page the long lifetime and the component the short one.
- Repeat the same evaluation after two weeks and put hit rate per template, variants per URL and backend load side by side.
A cache that does not hit is not half a cache. It is an extra layer that costs work and saves nothing. The difference is rarely in the configuration and almost always in the number of variants per URL.
The effort of this diagnosis is small compared with what it prevents: a bigger server that solves the same problem at a higher price. Usually an extended log format, one evaluation and two or three header changes are enough. If you would like to shorten the way in: in a performance analysis we count the variants per URL, name the cause per template and agree which hit rate per template goes into the acceptance test. How the cache fits into the rest of the delivery chain is described alongside on the service page about caching strategies.
Cache-Control: private is for, and the browser documentation recommends it explicitly instead of naming a cookie in Vary (MDN Web Docs). The value only stays sensible when a single cookie has a small, known set of states and the proxy normalises the header to exactly that value beforehand - a currency or a branch with a handful of variants, for example. The raw value of the cookie header never belongs in the key.Related Articles
TYPO3 Performance: Caching and Load Times Under Control
How the TYPO3 page cache works, which settings switch it off, and how the cache hash, the backends and the deployment decide the load time of a page.
AI Crawler Load: When Bots Slow Down Your Server
Bots generate over half of all HTML requests. Why AI crawlers hit uncacheable long-tail URLs, how to measure the load and throttle it instead of blocking.
CDN and Edge Caching for Shop Performance 2026
How a CDN, edge caching and well-set cache-control headers cut Time to First Byte worldwide and keep your shop fast even far from the origin server.