|
| 1 | +--- |
| 2 | +layout: post |
| 3 | +title: "Better Browser Caching with No-Vary-Search" |
| 4 | +date: 2026-05-07 21:31:40 |
| 5 | +categories: Web Development |
| 6 | +main: "" |
| 7 | +meta: "No-Vary-Search lets HTTP caches ignore irrelevant query parameters such as UTM tags, while still keeping meaningful ones like product variants in the cache key." |
| 8 | +--- |
| 9 | + |
| 10 | +I’ve [written](/2019/03/cache-control-for-civilians/), |
| 11 | +[spoken](https://speakerdeck.com/csswizardry/cache-rules-everything), and |
| 12 | +generally [gone on](/2025/03/why-do-we-have-a-cache-control-request-header/) [at |
| 13 | +length](/2024/08/cache-grab-how-much-are-you-leaving-on-the-table/) [about |
| 14 | +caching](/2023/10/the-three-c-concatenate-compress-cache/) for years now, but |
| 15 | +a newer addition to the conversation is `No-Vary-Search`: an HTTP response |
| 16 | +header that helps us solve a surprisingly common problem with cache keys in HTTP |
| 17 | +cache (or _browser cache_). |
| 18 | + |
| 19 | +The short version is, URLs that are materially the same often fail to reuse the |
| 20 | +same cached response simply because their query strings differ. Sometimes that |
| 21 | +difference matters to the content and, therefore, the end user. For example: |
| 22 | + |
| 23 | +* `?colour=red` |
| 24 | +* `?colour=blue` |
| 25 | + |
| 26 | +And sometimes, it really doesn’t matter at all: |
| 27 | + |
| 28 | +* `?utm_source=google` |
| 29 | +* `?utm_source=chatgpt` |
| 30 | + |
| 31 | +The former are very likely different pages, or at least pages that ought to |
| 32 | +produce different content. We would not want them cached under the same key. |
| 33 | + |
| 34 | +The latter should, in almost every sane setup, return the exact same HTML. They |
| 35 | +are the same page, just with different tracking baggage attached. |
| 36 | + |
| 37 | +And yet, to an HTTP cache, different query strings traditionally equate to |
| 38 | +completely different URLs, which means different cache entries. That is wasteful. |
| 39 | + |
| 40 | +## The Problem We’re Solving |
| 41 | + |
| 42 | +By default, caches are cautious. If the URL differs, the cache key differs, and, |
| 43 | +in a cautious world, that is usually the right thing to do. |
| 44 | + |
| 45 | +This is why: |
| 46 | + |
| 47 | +* `/products/shoes?colour=red` |
| 48 | +* `/products/shoes?colour=blue` |
| 49 | + |
| 50 | +…should remain distinct. The query parameter materially changes the content of |
| 51 | +the page. |
| 52 | + |
| 53 | +But this also means the cache will usually treat these as distinct pages, |
| 54 | +too—even if all three return byte-for-byte identical HTML. |
| 55 | + |
| 56 | +* `/sale?utm_source=google` |
| 57 | +* `/sale?utm_source=chatgpt` |
| 58 | +* `/sale?utm_source=newsletter` |
| 59 | + |
| 60 | +At best, that means wasted cache space; at worst, it means unnecessary trips |
| 61 | +across the network because the browser cannot reuse a perfectly good response |
| 62 | +that it already has stored. |
| 63 | + |
| 64 | +**This is exactly what `No-Vary-Search` addresses.** |
| 65 | + |
| 66 | +## What `No-Vary-Search` Does |
| 67 | + |
| 68 | +[`No-Vary-Search`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/No-Vary-Search) |
| 69 | +is a response header that tells the cache how to treat query parameters when |
| 70 | +matching a URL to an existing cache entry. |
| 71 | + |
| 72 | +In other words, it lets the server say, via a header, <q>these search parameters |
| 73 | +do not meaningfully change the response, so do not let them fragment the |
| 74 | +cache</q>. |
| 75 | + |
| 76 | +This is a little reminiscent of the well known |
| 77 | +[`Vary`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Vary) |
| 78 | +header, but aimed squarely at URL search parameters rather than request headers. |
| 79 | + |
| 80 | +That distinction matters. `Vary` says <q>this response depends on |
| 81 | +`Accept-Language`</q> or <q>this response depends on `Accept-Encoding`</q>, for |
| 82 | +example. `No-Vary-Search` says <q>this response does _not_ depend on |
| 83 | +`utm_source`</q> or <q>the order of these parameters should not matter</q>, and |
| 84 | +so on. |
| 85 | + |
| 86 | +If you know that certain parameters are irrelevant to the response body, you can |
| 87 | +tell the cache to ignore them, effectively allow- or blocklisting them for cache |
| 88 | +key purposes. |
| 89 | + |
| 90 | +## A Simple Example |
| 91 | + |
| 92 | +Imagine a landing page that is heavily used in campaigns, ads, email, and |
| 93 | +social posts: |
| 94 | + |
| 95 | +* `/offer?utm_source=google` |
| 96 | +* `/offer?utm_source=chatgpt` |
| 97 | +* `/offer?utm_source=linkedin` |
| 98 | + |
| 99 | +If those all return the same page, you can tell the cache to ignore |
| 100 | +`utm_source`: |
| 101 | + |
| 102 | +```http |
| 103 | +No-Vary-Search: params=("utm_source") |
| 104 | +``` |
| 105 | + |
| 106 | +Now, as far as cache matching is concerned, those URLs may all reuse the same |
| 107 | +stored response. They will no longer be treated as separate cache entries just |
| 108 | +because of the different `utm_source` values. Much more effective reuse of |
| 109 | +cached content. |
| 110 | + |
| 111 | +That is the crucial thing to understand: `No-Vary-Search` is not changing the |
| 112 | +URL, and it is not rewriting requests. It is changing how cache matching treats |
| 113 | +the differing query string. |
| 114 | + |
| 115 | +## `No-Vary-Search` Syntaxes |
| 116 | + |
| 117 | +The header has a few useful, different forms. |
| 118 | + |
| 119 | +### Ignore Specific Parameters |
| 120 | + |
| 121 | +This is the form I suspect most people will use most often: |
| 122 | + |
| 123 | +```http |
| 124 | +No-Vary-Search: params=("utm_source" "utm_medium" "utm_campaign" "fbclid" "gclid") |
| 125 | +``` |
| 126 | + |
| 127 | +This says: if the only differences between two URLs are those parameters, treat |
| 128 | +them as the same cache key. |
| 129 | + |
| 130 | +This is ideal for analytics and campaign tagging, where the query string is |
| 131 | +useful to _you_ but should not change the response for the user. These standard |
| 132 | +tracking and social parameters could probably be safely applied to most, if not |
| 133 | +all, sites. |
| 134 | + |
| 135 | +### Ignore All Parameters |
| 136 | + |
| 137 | +If your page genuinely does not vary by query string at all, you can be much |
| 138 | +broader: |
| 139 | + |
| 140 | +```http |
| 141 | +No-Vary-Search: params |
| 142 | +``` |
| 143 | + |
| 144 | +That is the boolean form of `params`, and it tells the cache to ignore _all_ |
| 145 | +search parameters for matching purposes. This works perfectly for my site which |
| 146 | +has zero back end, and thus cannot possibly vary by query string. |
| 147 | + |
| 148 | +This is obviously powerful, but also the easiest way to shoot yourself in the |
| 149 | +foot, so only use it if you really mean it. |
| 150 | + |
| 151 | +### Ignore Everything _Except_ Certain Parameters |
| 152 | + |
| 153 | +Sometimes the inverse is easier to express. Perhaps most parameters are |
| 154 | +irrelevant, but a small number genuinely change the response: |
| 155 | + |
| 156 | +```http |
| 157 | +No-Vary-Search: params, except=("colour" "size") |
| 158 | +``` |
| 159 | + |
| 160 | +This says: ignore all query parameters _except_ `colour` and `size`. |
| 161 | + |
| 162 | +That would be a decent fit for a page where: |
| 163 | + |
| 164 | +* `utm_*` tags do not matter, |
| 165 | +* client-side sorting/filtering or tracking parameters do not matter (remember, |
| 166 | + the HTML itself doesn’t change) but, |
| 167 | +* product variants (`?colour`, `?size`) do. |
| 168 | + |
| 169 | +In that world: |
| 170 | + |
| 171 | +* `/products/shoes?utm_source=google&colour=red` |
| 172 | +* `/products/shoes?utm_source=chatgpt&colour=red` |
| 173 | +* `/products/shoes?colour=red&sort=price` |
| 174 | + |
| 175 | +could share a cache entry, but: |
| 176 | + |
| 177 | +* `/products/shoes?colour=red` |
| 178 | +* `/products/shoes?colour=blue` |
| 179 | + |
| 180 | +should not. |
| 181 | + |
| 182 | +### Ignore Parameter Order |
| 183 | + |
| 184 | +Sometimes the parameters themselves matter, but their order does not: |
| 185 | + |
| 186 | +* `/search?q=shoes&sort=price` |
| 187 | +* `/search?sort=price&q=shoes` |
| 188 | + |
| 189 | +These should usually be treated as equivalent. For that, there is `key-order`: |
| 190 | + |
| 191 | +```http |
| 192 | +No-Vary-Search: key-order |
| 193 | +``` |
| 194 | + |
| 195 | +That tells the cache not to create separate entries just because the same |
| 196 | +parameters arrived, only in a different order. |
| 197 | + |
| 198 | +### Combining `No-Vary-Search` Rules |
| 199 | + |
| 200 | +You can combine directives: |
| 201 | + |
| 202 | +```http |
| 203 | +No-Vary-Search: key-order, params, except=("colour" "size") |
| 204 | +``` |
| 205 | + |
| 206 | +That tells the cache: |
| 207 | + |
| 208 | +* parameter order does not matter, and |
| 209 | +* all parameters may be ignored except `colour` and `size`. |
| 210 | + |
| 211 | +This means the three following URLs could all share a cache entry: |
| 212 | + |
| 213 | +* `/products/shoes?utm_source=google&colour=red` |
| 214 | +* `/products/shoes?colour=red&sort=price` |
| 215 | +* `/products/shoes?sort=price&colour=red` |
| 216 | + |
| 217 | +That is probably the most expressive form, and in real systems it may prove the |
| 218 | +most useful. This is phenomenally powerful. |
| 219 | + |
| 220 | +## A Small Syntax Gotcha |
| 221 | + |
| 222 | +`No-Vary-Search` uses [Structured |
| 223 | +Fields](https://www.rfc-editor.org/rfc/rfc8941) syntax, so the parameter lists |
| 224 | +are space-separated quoted strings: |
| 225 | + |
| 226 | +```http |
| 227 | +No-Vary-Search: params=("utm_source" "utm_medium" "gclid") |
| 228 | +``` |
| 229 | + |
| 230 | +…not comma-separated values that you may be used to in most other places. |
| 231 | + |
| 232 | +That is a small detail, but one worth being aware of. |
| 233 | + |
| 234 | +## A Small Debugging Gotcha |
| 235 | + |
| 236 | +Note that this also creates a slightly unusual debugging scenario. A very common |
| 237 | +way to force what we assume will be a fresh trip to the server is to throw |
| 238 | +a random search parameter on the end of the URL. I’m sure we’ve all done |
| 239 | +something like this before: |
| 240 | + |
| 241 | +* `/?foo` |
| 242 | +* `/?test` |
| 243 | +* `/?asdf` |
| 244 | + |
| 245 | +Ordinarily, that gives us a different URL and therefore a different cache key. |
| 246 | +But if the main document is using `No-Vary-Search`, that assumption may no |
| 247 | +longer hold. Appending search params may not bypass cache for this document |
| 248 | +because the cache has explicitly been told those parameters do not matter. |
| 249 | + |
| 250 | +Honestly, I would love DevTools to surface this more clearly. Something akin to |
| 251 | +the existing ⚠️ iconography in the _Network_ panel’s title, would be really |
| 252 | +helpful here: not because anything is wrong per se, but because the browser may |
| 253 | +be doing something surprising unless you know to look for the `No-Vary-Search` |
| 254 | +header. |
| 255 | + |
| 256 | +## Use It Carefully |
| 257 | + |
| 258 | +This header is only as good as the assumptions behind it: if two URLs really do |
| 259 | +return meaningfully different content, then they _need_ different cache entries. |
| 260 | +I’d rather be served the correct page a little more slowly than the wrong page |
| 261 | +quickly. |
| 262 | + |
| 263 | +This means `No-Vary-Search` is best suited to parameters that are: |
| 264 | + |
| 265 | +* purely analytical; |
| 266 | +* purely presentational on the client side, or; |
| 267 | +* otherwise irrelevant to the server-rendered response. |
| 268 | + |
| 269 | +If a parameter affects the HTML, do not ignore it. |
| 270 | + |
| 271 | +It is also worth noting that, as of **7 May 2026**, this is still an |
| 272 | +experimental feature and support is not yet universal, so I would treat it as a |
| 273 | +progressive enhancement rather than a foundational part of your caching |
| 274 | +strategy. |
| 275 | + |
| 276 | +## A Nice Fit for Messy Real-World URLs |
| 277 | + |
| 278 | +What I like about `No-Vary-Search` is that it acknowledges how the web actually |
| 279 | +works. |
| 280 | + |
| 281 | +URLs pick up baggage. Marketing tags get appended. Tracking parameters get |
| 282 | +added. Client-side state leaks into the address bar. Two URLs that are |
| 283 | +materially the same page often arrive wearing different coats. |
| 284 | + |
| 285 | +Historically, caches had to treat those as entirely separate keys. |
| 286 | + |
| 287 | +`No-Vary-Search` gives us a way to be a little more honest. If the response is |
| 288 | +the same, we can say so. And if only certain parameters matter, we can say that |
| 289 | +too. |
| 290 | + |
| 291 | +For teams who care about getting more out of HTTP cache, that is a very welcome |
| 292 | +addition. |
0 commit comments