
By Harry Roberts
Harry Roberts is an independent consultant web performance engineer. He helps companies of all shapes and sizes find and fix site speed issues.
CARVIEW |
(last updated on )
Written by Harry Roberts on CSS Wizardry.
Independent writing is brought to you via my wonderful Supporters.
I’ve written and
spoken many, many
times about the Cache-Control
response header and its many directives, but one
thing I haven’t covered before—and something I don’t think many developers are
even aware of—is the Cache-Control
request header. Unless you know your
caching well, those two links in the first sentence will make this article a lot
easier to understand. Maybe pop them open in another tab as a reference.
Let’s go!
Cache-Control
RecapAs developers, we’re most used to Cache-Control
as the preferred way of
instructing caches (usually browsers) on how they should store responses (if at
all), and what to do once their cache lifetime is up. Maybe something like this:
Cache-Control: max-age=2147483648, immutable
There’s a lot more to it than that, which you can read about in my 2019 piece, Cache-Control for Civilians
One thing we’re probably less used to is Cache-Control
’s employment as
a request header.
Cache-Control
as a Request HeaderIn a nutshell, the Cache-Control
request header determines whether the browser
retrieves content from the cache or forces a network request. It’s also used by
intermediaries such as CDNs to work out whether they should serve a response
themselves, or keep passing the request back upstream to origin.
It’s a way for the client to force freshness.
This means that the most common way you’re ever likely to see a Cache-Control
request header is when you refresh or hard refresh a page. Honestly, that’s
mostly it.
All browsers behave a little differently between refreshes, hard refreshes, and run of the mill revalidation.
In Chrome, even if the page is still fresh, refreshing it will dispatch a request to the network with the following request headers:
Cache-Control: max-age=0
[If-Modified-Since|If-None-Match]
Cache-Control: max-age=0
just means after zero seconds, revalidate this
resource. This isn’t incredibly strictly enforced so it’s technically a weak
instruction to revalidate. More on that later.If-Modified-Since
or If-None-Match
headers are revalidation request
headers that are used to compare the current version of the response with the
target version on the network.All other subresources on the page are fetched as per their caching headers, so there is no different or specific behaviour here.
In Firefox the behaviour is a little different. Refreshing a still-fresh page results in the following request headers:
[If-Modified-Since|If-None-Match]
No Cache-Control
request header at all, just the relevant revalidation
headers.
Again, all of the page’s subresources are treated as normal.
Safari is different still, and generally seems much more aggressive with its cache busting. Refreshing the same still-fresh page in Safari gives the following request headers:
Cache-Control: no-cache
Pragma: no-cache
We have a Cache-Control
request header, this time with a no-cache
directive.
While this is functionally equivalent to max-age=0
, the spec speaks much more
clearly that no-cache
means that a cache MUST NOT use the response to
satisfy a subsequent request without successful revalidation with the origin
server
. We also have the first appearance of Pragma
, also carrying
no-cache
. Pragma
is an incredibly outdated header that serves as a backward
compatibility measure for HTTP/1.0 caches. Safari including this here is a very
defensive measure!
Again, all other subresources are treated as they would be normally.
All browsers exhibit some similarities and some differences.
304
) if it’s still valid.Cache-Control
header, making it the least aggressive
of the three.Cache-Control
and
Pragma
headers, and no revalidation headers for potential reuse. Safari will
always return a 200
response to a refresh.Things are a little different when it comes to a hard refresh. Hard refreshes are usually a sign of user frustration and that something is badly broken or outdated. To this end, browsers begin upping the ante here.
In all browsers, a hard refresh causes both the main document and all of its subresources to be requested with the following:
Cache-Control: no-cache
Pragma: no-cache
Key things to note:
304
is not possible. We’re
always guaranteed a fresh response.max-age=0
to no-cache
. This is a clear signal of
intent that a hard refresh is more aggressive than a regular one.Note that this all applies to the main document and all of its subresources—even
immutable
assets—so
everything on the page is now guaranteed fresh. 304
responses are not
possible.
max-age=0
vs no-cache
Both of these directives behave incredibly similarly: max-age=0
means the
response is considered stale after zero seconds and therefore should be
revalidated, and no-cache
means don’t fetch this response from cache without
revalidating it first.
Where they differ is that max-age=0
permits caches to reuse a response if
revalidation isn’t possible (e.g. no network access); no-cache
is much
stricter—it means the cache must always revalidate before releasing a response,
or return an error if revalidation fails.
In the case that a user hasn’t refreshed the page, but instead they have a file
in their cache that is now considered
stale,
the browser needs to check with the server whether or not it needs a new copy,
or if it can reuse and renew the previously cached version. This is called
revalidation and is when the If-Modified-Since
or If-None-Match
headers
come into play.
If-Modified-Since
is used to check a file against its Last-Modified
response header.If-None-Match
is used to check a file against its Etag
response
header.When a file needs revalidating, all browsers behave the same:
[If-Modified-Since|If-None-Match]
They attach the relevant revalidation header, which will result in either
a 200
or 304
response in most cases. This is unremarkable other than the
fact that no browser attaches a Cache-Control
request header at this point.
Cache-Control
Request HeaderEach of these use cases was browser-defined, very much out of our hands as web
developers, but there are scenarios when we might want to (and can!) add our own
Cache-Control
request headers. Think of these scenarios as incredibly
aggressive, incredibly defensive bidirectional caching rules to absolutely
guarantee that no caches anywhere along with request–response lifecycle will
retain a copy of a response. By setting Cache-Control
at both ends, we have
a double-pronged approach to our strategy. A very cautious approach.
Imagine you’re building a sports betting site or stock trading app: realtime price updates are incredibly important, and all data must be up to date, always. You’d serve your responses with something like:
Cache-Control: no-store
…and make your requests with something like:
fetch("https://api.website.com/data", {
method: "GET",
headers: {
"Cache-Control": "no-store",
}
})
This is the bare minimum for modern and compliant caches, but the hyper-defensive version would be more like:
Cache-Control: no-store, no-cache, max-age=0, must-revalidate
Pragma: no-cache
…on your responses, and this in your requests:
fetch("https://api.website.com/data", {
method: "GET",
headers: {
"Cache-Control": "no-store, no-cache, max-age=0",
"Pragma": "no-cache"
}
})
The latter two examples are overkill and do contain a lot of redundancy, but they also won’t do any harm.
Note that if the data is also potentially sensitive and contains
user-specific data, you’d want to add private
to your Cache-Control
response
headers.
If you have an offline application, you can use only-if-cached
to only serve
a response if it’s in cache, otherwise returning a 504
.
fetch("https://api.website.com/offline-data", {
method: "GET",
headers: {
"Cache-Control": "only-if-cached"
}
})
Adding this request header ensures that the request would never hit the network.
While only-if-cached
might not be useful for most web pages, it can be handy
for offline-first applications, such as PWAs or news readers that prefer using
stored content rather than attempting a network request that might fail.
Cache-Control
, Pragma
, or revalidation
headers in refresh and hard refresh scenarios.max-age=0
and no-cache
both trigger revalidation, but no-cache
is much
stricter and requires a fresh response.Cache-Control
in requests when you need realtime data
or offline-first apps.Cache-Control: no-store, no-cache, max-age=0
Cache-Control: only-if-cached
Need a helping hand with your caching strategy? Schedule a performance audit.
Harry Roberts is an independent consultant web performance engineer. He helps companies of all shapes and sizes find and fix site speed issues.
Hi there, I’m Harry Roberts. I am an award-winning Consultant Web Performance Engineer, designer, developer, writer, and speaker from the UK. I write, Tweet, speak, and share code about measuring and improving site-speed. You should hire me.
Cache-Control
DOMContentLoaded
Still Matter?
max-age
calculator
max-age
Value?
I help teams achieve class-leading web performance, providing consultancy, guidance, and hands-on expertise.
I specialise in tackling complex, large-scale projects where speed, scalability, and reliability are critical to success.