NoVarySearch.com

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=shoes can serve a navigation to /search?q=shoes&utm_source=mail. The expects_no_vary_search field 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:

KeyValueMeaning
key-orderboolean (bare key = true)Parameter order doesn't matter
paramslist of strings: ("a" "b")Ignore these parameters
exceptlist 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:

HeaderDraft (IETF -10)Chrome
key-ordervalidworks
params=("utm_source")validworks
except=("id")validignored
params, except=("id")invalidworks
except=()validignored
params (bare)invalidworks

What this means in practice:

  • key-order and params=(…) 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 params next to except, 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 params form as "going to be deprecated", so Chrome is likely to move towards the draft. Prefer key-order and params=(…) 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):

  1. Both query strings are parsed into name/value pairs.
  2. Ignored parameters are removed (with params), or everything except the listed ones is removed (with except).
  3. With key-order, the remaining pairs are sorted by name. Repeated names keep their relative order.
  4. 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=1 may 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=%61 equals ?a=a: percent-encoding is decoded.
  • +, %20 and a literal space are all the same space.
  • ?a=1&&b=2 equals ?a=1&b=2, and ?a equals ?a=.
  • No Unicode normalization. caf%C3%A9 (é as one character) and cafe%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=%f6 and ?a=%ef%bf%bd match. 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 ignoreNever ignore
  • Campaign tags: utm_*
  • Ad click IDs: gclid, fbclid, msclkid
  • Referral tags read only by client-side analytics
  • Session and auth tokens, API keys
  • Signed URLs: signature, expires, X-Amz-*
  • Redirect targets: next, return_to, redirect_uri
  • Anything the server reads to build the page: language, currency, page number, sort order, filters

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")

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=newsletter is byte for byte the same page as /guide.
  • It reads every parameter by name, so their order never matters. That's what makes key-order safe 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 under params=(…) lists and Chrome's params / params, except=(…) forms were used every time, and prefetches under the draft's except forms 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: except without params in Chrome, params next to except under the draft, commas between list items, or unquoted names.
Visitors get the wrong page
An ignored parameter changes the response, for example lang or page. Remove it from params (or add it to except), 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_search doesn'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