The State of Browser Caching, Revisited
Thursday, 16 March 2017
A long, long time ago, I wrote some tests using XmlHttpRequest to figure out how well browser caches behaved, and wrote up the results.
Fast forward more than a decade, and much has changed; there are lots of new browser engines, and browser testing has taken off at Web Platform Tests.
For the past few years I’ve been promising Anne that I’d help Fetch with HTTP caching, and as part of finally doing that over the last week or two, I needed to integrate some tests into WPT. The results are interesting – and browser caching is looking better!
Below are my observations. It’s important to note that the tests are still evolving a bit (and not “official” yet), and that I’m likely to add more over time. I’m going to be opening bugs against browsers and, when appropriate, changing the tests and opening bugs against the specs.
Make sure you read the caveats; one thing
I’ll add here is that these tests are through the lens of Fetch; that should give a good indication
of how the browser will behave, but it’s not definitive (e.g., see this
issue). I’ve only tested Firefox, Safari Tech Preview
(Safari release doesn’t support Fetch yet) and Chrome; Edge apparently doesn’t use its cache at
all for Fetch.
UPDATE: Mike Bishop at Microsoft suggested trying the Insider build with some flags turned on, and that did the trick (thanks, Mike!), so I’ve added results for Edge below (and if I say “all browsers tested”, it includes Edge). As with Safari Tech Preview, I suspect this is how the cache behaves for non-Fetch requests in the stable build.
- Explicit Freshness
- Heuristic Freshness
- Validation
- Invalidation
- Status Codes
- Request Cache-Control
- Vary
- Partial Content
Explicit Freshness
The core mechanism in HTTP caching is freshness – i.e., allowing the server to specify a lifetime when the response can be reused.
There are two basic ways to explicitly specify freshness; with the Expires
response header, and
the Cache-Control: max-age
response directive.
All of the browser caches tested honour both, and do so correctly; if the response is fresh, it
comes from cache, and if it is stale, it doesn’t. They also correctly prefer Cache-Control:
max-age
over Expires
when both are present.
If Expires
is invalid (e.g., the string 0
), they’ll consider the response stale – unless
there’s a Cache-Control: max-age
.
Cache-Control: no-store
in responses bypasses all tested browser caches correctly, and
Cache-Control: no-cache
in responses will correctly get stored, but checked on each request.
Of course, browsers caches aren’t necessarily the only caches in between the origin server and
fetch(); the origin server itself, CDNs, reverse proxies, intercepting proxies and forward proxies
all might implement a cache as well. That means that the browser’s cache needs to take account of
the Age
header when calculating freshness; it represents how much time the response spent in
these other caches.
All of the tested browser caches include Age
in their calculation of handling Cache-Control:
max-age
, with one hiccup; Firefox will return a stale response if you make a request for it
immediately after the cache is populated (if you wait a second or two, it will go back to the
network). I can speculate as to why this might be, but I’ll raise an issue to be sure.
In summary – this is good; we can rely on browser caches to get the basics right. In particular, it’s not necessary to put a salad of Cache-Control
directives into your response to cache-bust; no-store
and no-cache
work perfectly well.
Heuristic Freshness
If a response doesn’t have explicit freshness information like Expires
or Cache-Control:
max-age
, HTTP still allows it to be cached using what’s called heuristic
freshness. This means that the cache can
guess a freshness lifetime, and it’s useful because servers often don’t assign an explicit
freshness lifetime to responses, harming efficiency. Web caching never would have gotten started
without it.
However, the response has to allow this. Some status codes allow it by
default; e.g., 200 OK
, 404 Not
Found
, 410 Gone
and 501 Not Implemented
. If the status code doesn’t allow it, it can be
explicitly turned on using Cache-Control: public
.
Usually, caches calculate the actual freshness lifetime to use with Last-Modified
; if it’s a long
time ago, they have higher confidence that the resource won’t change soon, so they assume a longer
lifetime.
All tested browser caches apply heuristic freshness to 200 OK
. All except Edge also apply it
to 203 Non-Authoritative Information
and 410 Gone
responses.
Safari also applies it to 204 No Content
, 404 Not Found
, 405 Method Not Allowed
, 414 URI Too
Long
, and 501 Not Implemented
– all of the status codes allowed by HTTP.
None of the tested browsers applied heuristic freshness to a status code that HTTP disallows it
with, and none of them used it with an unknown status code in conjunction with Cache-Control:
public
.
Again, the basics are good here. It might be helpful if browsers applied heuristic freshness to
more responses (especially 404
), but people should be specifying explicit freshness (and avoiding
404
s on their subresources!) anyway.
The lack of support for using Cache-Control: public
to flag unknown status codes for heuristic
caching is a little disappointing, but not really surprising.
Validation
Validation allows a cache to check with the server to see if a stale stored response can be reused.
All of the tested browsers support validation based upon ETag
and Last-Modified
. The tricky
part is making sure that the 304 Not Modified
response is correctly combined with the stored
response; specifically, the headers in the 304
update the stored response headers.
All of the tested browsers do update stored headers upon a 304
, both in the immediate response
and subsequent ones served from cache.
This is good news; updating headers with a 304
is an important mechanism, and when they get out
of sync it can cause problems.
Invalidation
Because a request can change state on the server, it’s important to invalidate the contents of the cache when this happens, so that the user sees the freshest response. For example, if you post a comment, you want to see that comment in the resulting Web page when you get redirected to it.
HTTP specifies that caches should be invalidated when unsafe request methods are used and the
response is successful – i.e., a 2xx
or 3xx
status code. Unsafe methods include POST, PUT
and DELETE, as well as unknown status codes.
When this happens, caches are required to invalidate the URL that the request was made to, as well
as any Location
and Content-Location
URLs that the response might have.
All tested browsers correctly invalidate the requested URL for POST, PUT and DELETE requests.
Firefox also invalidates upon unknown methods; Safari, Edge and Chrome do not. Likewise, Firefox
also correctly invalidates the Location
and Content-Location
URLs; Safari, Edge and Chrome do
not.
All of the browser caches seem to ignore the response status code; they’ll invalidate (if they support invalidation in the test scenario) even if the request was unsuccessful. This isn’t critical, it just means that they’re a tiny bit less efficient then they could be.
Hopefully, Safari, Edge and Chrome will get to parity with Firefox here.
Status Codes
In HTTP, any status code with a freshness lifetime (e.g., from Expires
) can be cached. This
assures that new status codes can get the benefits of caching without waiting for caches to
be updated.
Chrome does this for all status codes tested; it will cache not only 200 OK
but also 404 Not
Found
, 500 Internal Server Error
and even 499 Unknown
, as long as they have explicit freshness
information.
Firefox caches a couple, like 203 Non-Authoritative Information
and 410 Gone
, but it doesn’t
cache many others; 204 No Content
, 299 Unknown
, 400 Bad Request
, 404 Not Found
, 499
Unknown
, 500 Internal Server Error
, 502 Bad Gateway
, 503 Service Unavailable
, 504 Gateway
Timeout
, or 599 Unknown
all goes straight to the network, even though they had explicit
freshness information.
Safari caches more of them, only balking at 400 Bad Request
, the 5xx
status codes and all
three unknown status codes (299
, 499
and 599
).
Edge only caches 200
; other tested status codes aren’t served from cache, even with explicit
freshness information.
Ideally, Edge, Firefox and Safari will see Chrome’s example and update; if it works for Chrome, it’s hard to argue that it’s going to cause compatibility issues.
Request Cache-Control
HTTP also allows the request to contain information that influences cache behaviour, in the
Cache-Control
request header
directives.
For example, max-age
, max-stale
, and min-fresh
do what they sound like; they put boundaries
on the age, staleness and freshness of the response used, respectively. This needs to take into
account not only the Expires
and Cache-Control
response headers, but also the Age
response
header (see above).
Of the tested browsers, Firefox supports max-age
, max-stale
and min-fresh
in requests,
both with a simple freshness lifetime, and when the Age
header is present.
Safari supports max-age=0
only; if there is any other value, or an Age
header in the
response, max-age
seems to be ignored in requests. It also supports max-stale
(both with and
without an Age
response header), but not min-fresh
.
Chrome only supports max-age=0
in requests, and only with the value 0
. It does not
support min-fresh
or max-stale
.
Edge doesn’t support max-age
, max-stale
or min-fresh
in requests. Worrisomely, it
doesn’t even pay attention to max-age=0
.
Additionally, HTTP defines no-store
to indicate that the cache should be bypassed completely for
this request (and its response). Only Firefox and Edge seem to support no-store
in
requests.
no-cache
means something slightly different than you might think; it allows use of the cache, but
forces the request to go to the origin server to validate any stored response. All
tested browser caches support it.
Finally, only-if-cached
allows you to ask to see if something is in the cache, but if it isn’t,
to abort the request and return a 504
response instead. None of the browser caches tested
supported it – possibly as a way to make cache probing just a little bit harder.
The interop story here isn’t great, but Firefox is doing great work at least.
Vary
HTTP lets servers specify that the cache key depends on more than just the URL by using the Vary
header, which lists the request headers whose values should be added to it. This secondary cache
key enables things like caching
compressed content, and generally in supporting content
negotiation.
The good news is that all browser caches tested support Vary
correctly; the correct
response is used, even when a two- or three-way Vary
header is sent.
Importantly, they also do the right thing when one of the Vary
ing request headers is missing
from the request.
The one hiccup I saw was that all tested browser caches would only store one variant at a time;
i.e., if your responses contain Vary: Foo
and you get two requests, the first with Foo: 1
and
the second with Foo: 2
, the second response will evict the first from cache.
Whether that’s a problem depends on how you use Vary
; if you want to reuse cached responses with
old values, it might reduce efficiency. However, that doesn’t seem like a common use case; it’ll be
interesting to see if there’s any impact on Client
Hints.
Overall, though, it’s a great relief that Vary
support is in such good shape; it was a real
problem not that long ago. Although Edge hasn’t been tested yet…
Partial Content
HTTP caches are allowed to store partial
content – whether that’s explicitly
retrieved with a Range
request and conveyed in a 206 Partial Content
response, or because the
connection dropped before the entire response was received.
I created a few partial content tests, but support seems to be poor in the tested browser caches; only Safari showed any positive results, and that was only the most simple test.
How problematic this is depends on your use case, but it’s definitely a niche; this won’t affect the vast majority of developers, since the most common uses for partial content are PDFs and video.