No-Vary-Search Guide
How the header works, where the draft and Chrome disagree, which parameters are safe to ignore, and how to fix a header that does nothing.
What it does
Caches normally treat every distinct URL as a different resource, query string included. /shoes?utm_source=mail and /shoes?utm_source=ads get two cache entries even when the server sends the same page for both.
No-Vary-Search is a response header that tells caches which query parameters don't change the response. A cache that supports it can then answer a request from a stored response whose URL differs only in those parameters.
No-Vary-Search: key-order, params=("utm_source" "utm_medium")
This one says: ignore the order of parameters, and ignore utm_source and utm_medium. Everything else in the query must still match.
Where it applies
- The browser HTTP cache. A page fetched once can be reused for the same URL with different tracking tags.
- Prefetch and prerender. With the Speculation Rules API, a page prefetched as
/search?q=shoescan serve a navigation to/search?q=shoes&utm_source=mail. Theexpects_no_vary_searchfield tells the browser the header in advance, so it can wait for a prefetch that is still loading instead of starting a new request. - Shared caches (CDNs, proxies). The draft lets them use it too. Support varies, see below.
It works alongside Vary, not instead of it: Vary is about request headers, No-Vary-Search is about the URL. A response reused through No-Vary-Search must still match on every header listed in Vary, and must still be fresh.
Syntax
The value is a Structured Fields Dictionary (RFC 9651) with three keys:
| Key | Value | Meaning |
|---|---|---|
key-order | boolean (bare key = true) | Parameter order doesn't matter |
params | list of strings: ("a" "b") | Ignore these parameters |
except | list of strings: ("id") | Ignore every parameter except these |
Lists are written in parentheses, items are separated by spaces, not commas, and every name is a quoted string. params=("a", "b") and params=(a b) are both broken, and a broken header is ignored completely. Names are percent-decoded and + means a space, so a non-ASCII name like é is written "%C3%A9".
The draft and Chrome disagree
Chrome implemented an earlier version of the draft, and MDN documents that version. The current draft (-10) changed how params and except work, and Chrome's parser (source, checked November 2026) has not followed:
| Header | Draft (IETF -10) | Chrome |
|---|---|---|
key-order | valid | works |
params=("utm_source") | valid | works |
except=("id") | valid | ignored |
params, except=("id") | invalid | works |
except=() | valid | ignored |
params (bare) | invalid | works |
What this means in practice:
key-orderandparams=(…)lists work in both. If your policy can be written that way, it's portable.- "Ignore all except …" and "ignore all" have no portable form today. Chrome needs
paramsnext toexcept, and the draft rejects any header that has both. - Either way, a header the other side rejects is simply ignored: URLs must then match exactly. Nothing breaks, you just lose the cache hits.
- Chromium's code marks the boolean
paramsform as "going to be deprecated", so Chrome is likely to move towards the draft. Preferkey-orderandparams=(…)lists whenever your policy allows it.
The checker shows both verdicts, and the generator gives both forms when they differ.
How a cache matches a URL
When a request arrives, a cache first looks for a stored response with exactly that URL. If there is none, it can look at stored responses for the same scheme, host, port and path, and compare query strings using the No-Vary-Search rule that came with the stored response (§6 of the draft):
- Both query strings are parsed into name/value pairs.
- Ignored parameters are removed (with
params), or everything except the listed ones is removed (withexcept). - With
key-order, the remaining pairs are sorted by name. Repeated names keep their relative order. - The remaining pairs must be identical, in number, names and values.
Some things follow from this:
- The rule comes from the response, so the first request for a page always goes to the server. The cache learns the rule from that answer.
- Send the same value on every response for a path, including redirects and error pages. The draft (§7) lets caches keep only the newest rule per path, so a header that changes over time makes them miss responses they could have reused.
- Changing the header doesn't invalidate existing entries. A POST to
?id=1may not clear a cached?id=1&utm_source=x.
Try it with real URLs in the URL tester, which shows every step.
How query strings are compared
Queries are parsed the way HTML forms are encoded (application/x-www-form-urlencoded), which hides some differences:
?a=%61equals?a=a: percent-encoding is decoded.+,%20and a literal space are all the same space.?a=1&&b=2equals?a=1&b=2, and?aequals?a=.- No Unicode normalization.
caf%C3%A9(é as one character) andcafe%CC%81(e plus an accent) stay different. Build links in one place so they're encoded the same way. - Invalid UTF-8 becomes U+FFFD, so
?a=%f6and?a=%ef%bf%bdmatch. The draft warns not to rely on that.
Without a header (or with a broken one), none of this applies: the query must match character for character, so even /a and /a? are different.
Safe and unsafe parameters
In a browser cache, a wrong rule affects one person, who might see a stale or wrong page. In a shared cache like a CDN, a wrong rule can serve one user's page to another user. The draft (§8) says never to ignore parameters used for authorization, user identity, signatures, consent, routing, auditing or revocation.
| Usually safe to ignore | Never ignore |
|---|---|
|
|
The simple test: if the server reads the parameter, it must not be ignored. With except this is easy to get wrong later, because every new parameter is ignored automatically. The checker warns about names that look sensitive.
Recipes
Each recipe opens in the checker. When the draft and Chrome need different headers, both are shown.
Ignore campaign tracking parameters
Links from newsletters and ads carry UTM tags that the server never reads. Listing them lets every tagged link share the cached page.
No-Vary-Search: params=("utm_source" "utm_medium" "utm_campaign" "utm_term" "utm_content")
Ignore ad click IDs
Click IDs are unique per click, so without this rule every ad visit creates a cache entry that is used once.
No-Vary-Search: params=("gclid" "fbclid" "msclkid")
Ignore parameter order
?a=1&b=2 and ?b=2&a=1 share a cache entry. Names and values must still match.
No-Vary-Search: key-order
Ignore order and tracking parameters
The most common real-world header. Both forms are the same, so it works everywhere.
No-Vary-Search: key-order, params=("utm_source" "utm_medium" "utm_campaign" "gclid" "fbclid")
Only the id parameter matters
Every parameter except id is ignored, including ones added later. Use it only when you control every link to the page.
Draft (IETF -10)
No-Vary-Search: except=("id")
Chrome
No-Vary-Search: params, except=("id")
Search results: only the query and page number matter
Careful: a filter or sort parameter you add later is ignored too, so ?q=shoes&sort=price would get the unsorted page. List every parameter the server reads.
Draft (IETF -10)
No-Vary-Search: key-order, except=("q" "page")
Chrome
No-Vary-Search: key-order, params, except=("q" "page")
Ignore all query parameters
The page is the same whatever the query string, so /page?anything reuses /page. Rarely right for pages with any server-side logic.
Draft (IETF -10)
No-Vary-Search: except=()
Chrome
No-Vary-Search: params
Prefetch a search page and keep tracking tags out of the way
Send No-Vary-Search: params=("utm_source" "utm_medium") on the response, and repeat the same value in the speculation rules so the browser can use a prefetch that hasn't finished yet:
<script type="speculationrules">
{
"prefetch": [
{
"source": "list",
"urls": [
"/search?q=shoes"
],
"expects_no_vary_search": "params=(\"utm_source\" \"utm_medium\")"
}
]
}
</script>
The hint must match the header the server really sends. If they differ, the browser may wait for nothing or skip a usable prefetch.
With a strict Content Security Policy that blocks inline scripts, put the rules in a JSON file instead (served as application/speculationrules+json) and announce it with a response header. No CSP change is needed; this site does it that way:
Speculation-Rules: "/speculation-rules.json"
This site uses it too
Every page on NoVarySearch.com sends this header, so you can compare yours with a real one:
No-Vary-Search: key-order, params=("utm_source" "utm_medium" "utm_campaign" "utm_term" "utm_content" "utm_id" "gclid" "fbclid" "msclkid")
It's true for this site because of two things the server does, and our tests check both:
- It drops tracking parameters before it builds a page, so
/guide?utm_source=newsletteris byte for byte the same page as/guide. - It reads every parameter by name, so their order never matters. That's what makes
key-ordersafe here.
Pages are also cacheable for ten minutes (Cache-Control: public, max-age=600). That matters: without a cacheable response there is nothing for No-Vary-Search to reuse. Error pages are no-store and send no No-Vary-Search.
See it for yourself:
curl -sI "https://novarysearch.com/guide?utm_source=test" | grep -i -e no-vary-search -e cache-control
Or try it in the URL tester: the first URL reuses the cached page, and the second doesn't, because lang isn't on the list. We also checked it in Chrome 156: navigating from /guide to /guide?utm_source=newsletter&gclid=abc is served from the HTTP cache without a request.
The site prefetches too. Every page sends Speculation-Rules: "/speculation-rules.json", and those rules prefetch a link on this site when your pointer rests on it, with the same header as the expects_no_vary_search hint:
{
"prefetch": [
{
"where": {
"href_matches": "/*"
},
"eagerness": "moderate",
"expects_no_vary_search": "key-order, params=(\"utm_source\" \"utm_medium\" \"utm_campaign\" \"utm_term\" \"utm_content\" \"utm_id\" \"gclid\" \"fbclid\" \"msclkid\")"
}
]
}
In Chrome 156, hovering the Guide link in the menu prefetches /guide, and opening /guide?utm_source=newsletter uses that prefetch without a new request. You can watch it in DevTools under Application → Speculative loads.
Browser and CDN support
- Chrome and other Chromium browsers use it in the HTTP cache (enabled by default in Chromium's source) and for prefetch and prerender with speculation rules, with the parsing differences described above.
We tested Chrome 156 (November 2026). HTTP cache: every header behaved exactly as in the table above, in repeated runs. Prefetch: the same, with Chrome's own reasons: prefetches underparams=(…)lists and Chrome'sparams/params, except=(…)forms were used every time, and prefetches under the draft'sexceptforms were never used ("successful but not used"). - Firefox and Safari: we haven't confirmed their status yet. http.dev reports HTTP cache support from Firefox 154 and no support in Safari.
- CDNs: check your CDN's documentation. Many build the cache key from the full query string and have their own settings for it (a list of parameters to ignore, or to keep). Set the same rule there so the browser and CDN agree.
The draft calls the feature best-effort: a cache is never required to reuse a response, so treat it as an optimization, not something your site depends on.
Troubleshooting
First, look at the header your server actually sends (redirects and error pages too):
curl -sI "https://example.com/search?q=shoes" | grep -i no-vary-search
- The header is sent, but nothing is reused
- The header is probably rejected, and then it's ignored silently. Paste it into the checker and look at both verdicts. The usual causes:
exceptwithoutparamsin Chrome,paramsnext toexceptunder the draft, commas between list items, or unquoted names. - Visitors get the wrong page
- An ignored parameter changes the response, for example
langorpage. Remove it fromparams(or add it toexcept), then purge the cached URLs. - It works on some URLs but not others
- Some responses for the path send no header or a different one: redirects, error pages, or another backend. Send the same value everywhere.
- A prefetch isn't used when the user clicks
- The clicked URL differs in a parameter that still counts, or
expects_no_vary_searchdoesn't match the real header. In Chrome DevTools, the Application panel lists prefetches under Speculative loads, with the reason each one was or wasn't used. - The CDN still misses
- Your CDN may not read
No-Vary-Search. Configure its query-string cache-key settings to match. - The same text doesn't match
- Links built by different systems can encode accented characters differently (NFC vs NFD). Normalize them to one form when you build links.
See also
- The No-Vary-Search HTTP Caching Extension (IETF draft, editor's copy)
- The draft on the IETF Datatracker (versions and status)
- Chromium's No-Vary-Search parser
- Speculation Rules API (MDN)
- Vary and cache keys (RFC 9111)
- Structured Field Values (RFC 9651)