The browser cache is Vary broken

Jake, why are your blog posts always so depressing?

Domenic Denicola (@domenic)

Well, I wouldn't want to disappoint…

TL;DR If you use "Vary" to negotiate content, the responses will fight for the same cache space. Additionally, IE ignores "max-age" and Safari is buggy.

Content negotiation using "Vary"

An item in the HTTP cache is matched by its URL and method (GET, POST etc), but you can specify additional constraints via the "Vary" header. You can use this to serve different content…

  • depending on language via Vary: Accept-Language
  • depending on supported types, eg WebP for images via Vary: Accept
  • when client hints ship, depending on screen DPR and render-width via Vary: CH-DPR, CH-RW

Some people are against content negotiation, but I thought it worked well for the cases above. However if you mix it with caching, browsers barf in your face. In your face.

Serving WebP conditionally

Put your eyes on this:

Some dice

The above is a 90k PNG, unless you're in a browser that supports (and advertises) WebP, then you get a 44k WebP equivalent. At the moment you'll get the WebP in Chrome and Opera.

The server looks to see if the "Accept" request header contains 'image/webp', then it serves the relevant file, instructs it to cache for an hour via Cache-Control: max-age=3600, and uses Vary: Accept to indicate the response differs depending on the "Accept" header.

IE fails us

All versions of IE ignore the max-age=3600 bit when "Vary" is present, so they go back to the server for revalidation. For the above image, that means downloading the whole thing again, although from IE7 onwards you can reduce this to an HTTP 304 by using an ETag.

MSDN has more details on IE's caching of "Vary" responses.

To avoid this, don't send the "Vary" header to IE, and prevent intermediate caches storing it with Cache-Control: max-age=3600, private. Ilya Grigorik covers the server setup for this.

And the other browsers?

In this case, Firefox, Chrome, Opera and Safari do pretty well. Although there are some caching oddities lurking underneath that you'd only see if you XHR fetched the image with a different accept header. For example…

Serving a language pack conditionally

Greeting: Loading…

When you change the select above, it makes a GET request for "/demos/conditional-lang-cached/lang-pack" with the "Accept-Language" header set to whatever you select. The response is set to cache for an hour via Cache-Control: max-age=3600.

Many responses, only one gap in the cache

Unless you're using IE, pressing "Re-request" doesn't result in a request to the server, max-age=3600 is respected and the cache is used. However, if you switch to another language then back again, you get two full server requests, ETags don't even help.

I assumed the browser would cache both responses independently, but no, the "Vary" header is used for validation, not keying. Here's what the browser does:

  • Request "English"
  • Look for an entry in the cache for the URL+method, none found
  • Request from the network
  • Cache it against the URL+method
  • Request "Scottish"
  • Look for an entry in the cache for the URL+method, "English" entry found
  • "English" response has Vary: Accept-Language, but the "English" request has a different "Accept-Language" header to this request, so the cache item doesn't match
  • Request from the network
  • Cache it against the URL+method, which overwrites the "English" entry

So, if you want to get the most out of caching, use different URLs rather than content negotiation, else your responses will be fighting over the same space in the cache. I hope we can fix this before Client Hints ship.

Changing API response type based on Accept

Loading…

When you change the select above, it makes a GET request for "/demos/conditional-content-cached/code" with the "Accept" header set to whatever you select. The response is set to cache for an hour via Cache-Control: max-age=3600.

This is similar to the WebP example, except this time the browser can make multiple requests with different headers, as we did with "Accept-Language".

Most browsers exhibit the same caching issues we saw with the previous example, except…

Safari isn't listening

The example above doesn't work in Safari. No matter what you select, it'll fetch the previous response from the cache, meaning it comes back with the wrong content. You ask for JSON, it fetches the JavaScript response from the cache. It's ignoring the "Vary" header in this case, despite getting it right in the "Accept-Language" example.

Once again, having different URLs would have avoided this, and given us better caching in other browsers, but this is a bug in Safari (bug ticket).

Why don't browsers cache these things independently?

It wasn't clear to me until I tried to write an implementation. Let's say we make a GET request to "/whatever/url":

# Request:
Accept: application/json

# Response
Cache-Control: max-age=3600
Vary: Accept
…some JSON…

Now let's make another request:

# Request:
Accept: */*

# Response
Cache-Control: max-age=3600
…some HTML…

Say we cached both, what happens if we made this request?…

# Request:
Accept: application/json

Unfortunately the above request matches both entries in the cache. The first entry matches because we vary on "Accept", but the "Accept" header is the same. The second entry matches because it doesn't care about the "Accept" header, it has no "Vary" header.

It feels like the first entry should be the match because it's more specific, but HTTP caching has no concept of specificity. Having different "Vary" headers for the same URL messes things up.

I recently drafted a programmable HTTP cache for the ServiceWorker, the rule we use here is "first match wins". But when adding to the cache, anything the request matches is overridden. So if we added a new entry:

# Request:
Accept: application/json

# Response
Cache-Control: max-age=3600
Vary: Accept
…some newer JSON…

…that would overwrite both previous entries because the request matches. Otherwise, we'd be adding a request to the cache that cannot be reached, because the Accept: */* entry would always match first.

Hopefully that'll work!

View this page on GitHub

Comments powered by Disqus

Jake Archibald next to a 90km sign

Hello, I’m Jake and that is my tired face. I’m a developer of sorts.

Elsewhere

Contact

Feel free to throw me an email, unless you're a recruiter, or someone trying to offer me 'sponsored content' for this site, in which case write your request on a piece of paper, and fling it out the window.