Are Your Cache-Control Directives Doing What They Are Supposed to Do?
HTTP offers very powerful support for caching:
The goal of caching in HTTP/1.1 is to eliminate the need to send requests in many cases, and to eliminate the need to send full responses in many other cases. The former reduces the number of network round-trips required for many operations; we use an "expiration" mechanism for this purpose (see section 13.2). The latter reduces network bandwidth requirements; we use a "validation" mechanism for this purpose (see section 13.3).
If you're not familiar with how the "expiration" and "validation" mechanisms work in different kinds of HTTP caches, you should start with Things Caches Do by Ryan Tomayko. For a more in-depth look at HTTP caching, check out Caching Tutorial for Web Authors and Webmasters by Mark Nottingham.
Private and Shared Caches
Private caches can only serve a single user, whereas shared caches can serve any number of users. To take full advantage of HTTP caching, you need to have a private cache on your clients, and a shared gateway cache (also known as reverse proxy cache or surrogate cache) just in front of your backend.
The classic example of a private client cache is the browser cache. HttpResponseCache and NSURLCache are examples of private client caches for Android and iOS respectively.
Varnish and Squid are examples of gateway caches. CDNs such as Akamai and Fastly are essentially geo-distributed gateway caches.
Cache-Control
Header
Cache-Control
header is used to control how the caches between your user and application
behave. It can be used as a request or response header (similar to Content-Type
). But, for
the purposes of this blog post, we're going to explore using Cache-Control
as a response
header from your backend application.
Note that there can be any number of shared caches between the private cache on the client, and the gateway cache right in front of your backend. If you use HTTPS, you don't have to worry about how these intermediate caches (i.e. proxy cache installed on a company network) would behave. At theScore, we leverage HTTP caching in all of our APIs. Furthermore, all of our APIs use HTTPS as such we don't have to worry about intermediate caches, and this blog post will do the same for the most part.
Cache-Control
Directives
If you use Cache-Control
as a response header, there are only 9
directives to worry about. That's it!
Here's a summary of the directives from Caching Tutorial for Web Authors and Webmasters
by Mark Nottingham:
Useful
Cache-Control
response headers include:
max-age=[seconds]
- specifies the maximum amount of time that a representation will be considered fresh. Similar toExpires
, this directive is relative to the time of the request, rather than absolute.[seconds]
is the number of seconds from the time of the request you wish the representation to be fresh for.
s-maxage=[seconds]
- similar tomax-age
, except that it only applies to shared (e.g., proxy) caches.
public
- marks authenticated responses as cacheable; normally, if HTTP authentication is required, responses are automatically private.
private
- allows caches that are specific to one user (e.g., in a browser) to store the response; shared caches (e.g., in a proxy) may not.
no-cache
- forces caches to submit the request to the origin server for validation before releasing a cached copy, every time. This is useful to assure that authentication is respected (in combination with public), or to maintain rigid freshness, without sacrificing all of the benefits of caching.
no-store
- instructs caches not to keep a copy of the representation under any conditions.
must-revalidate
- tells caches that they must obey any freshness information you give them about a representation. HTTP allows caches to serve stale representations under special conditions; by specifying this header, you’re telling the cache that you want it to strictly follow your rules.
proxy-revalidate
— similar tomust-revalidate
, except that it only applies to proxy caches.
You can mix and match these directives in a number of different combinations:
Cache-Control: private, max-age=10
Cache-Control: max-age=10, s-maxage=300
Cache-Control: max-age=10, s-maxage=300, proxy-revalidate
As you can see, the directives are pretty straightforward to understand. They're easy to use as well if you assume that all the caches between your end user and application correctly implement the spec. Unfortunately, as with any spec, you can't make that assumption. You need to be aware of any spec misinterpretations in the implementation of the caches that you're using, and properly account for them.
Getting Cache-Control
Right
Let's look at a scenario, in which we consider most of the Cache-Control
directives from
above in the process of arriving at the final combination.
Suppose you have a mobile application that consumes a REST API, and one of the API end-points
has Cache-Control: max-age=7200
(the response is considered fresh for 2 hours). Further suppose
that due to a new bug in the API, the response for this end-point changes in a way that causes
the app to crash. You can fix the bug and purge the cache for this end-point on your gateway
cache (gateway caches generally give you this kind of control). But, this won't stop the clients
that have the bad response cached from crashing. In the worst case, these clients will continue
to crash for up to 2 hours. You essentially get stuck with stale content, and just have to
wait it out.
To avoid getting stuck with stale content on a client, we want to make sure that the client always revalidates the content before serving it from the client cache. We would still save bandwidth with this approach. Having said that, we still want the original behaviour (the response is considered fresh for 2 hours) on the gateway cache.
It looks like no-cache
directive fits the bill perfectly as it allows you "to maintain rigid
freshness, without sacrificing all of the benefits of caching." Note that no-cache
does not
mean "not to cache" (you use no-store
for that). But, sooner than later, we would discover
that some popular client caches (certain versions of IE and Firefox) treat no-cache
as
no-store
. So, let's abandon this option and start with the following instead:
Cache-Control: max-age=0
With this in place, the response expires right away, which will force revalidation. However,
this will force revalidation on the gateway cache as well. In other words, all the requests
will end up hitting the backend. This can be fixed by using s-maxage
:
Cache-Control: max-age=0, s-maxage=7200
Caches can be configured to serve stale content (i.e. in case of network error during revalidation), but we can enforce that stale content is not served even with such configuration or under any other circumstances:
Cache-Control: max-age=0, s-maxage=7200, must-revalidate
On a second look, we might decide it's best to serve stale content from client cache since
revalidation can fail due to network errors that can happen more often than not in a
mobile application. However, we still don't want our gateway cache to serve stale content under
any circumstances. We can achieve this by using proxy-revalidate
instead of must-revalidate
:
Cache-Control: max-age=0, s-maxage=7200, proxy-revalidate
This is the final combination of directives we need to handle the scenario we originally described at the beginning of this section.
Beyond Cache-Control
The solution we came up above will not work if we need to worry about any intermediate
shared (proxy) caches between the client cache and gateway cache (this is the case if we
don't use HTTPS). This is because s-maxage
applies to those caches as well, and we have no
control over purging cached content on them. We need a way to specify directives that
only apply to our gateway cache. Enter Surrogate-Control.
Surrogate-Control
is like Cache-Control
except it only applies to gateway caches (also
known as surrogate caches). It is part of a draft spec, but some popular gateway caches
(i.e. Squid and Fastly) already support it. The gateway caches that don't support
Surrogate-Control
usually have their own custom header that works the same way (i.e.
Akamai has Edge-Control
).
Now you know about Surrogate-Control
, the final solution to handle the scenario described
in the last section is to use both Cache-Control
and Surrogate-Control
:
Cache-Control: max-age=0
Surrogate-Control: max-age=7200, must-revalidate
Conclusion
HTTP caching is powerful, but severely underused. Even when it is used, people rarely
leverage it to its maximum potential. Take the time to read all of Caching in HTTP
section of HTTP/1.1 RFC now (even if you've already read it before). And, don't forget
to pay close attention to the subsection on Cache-Control
.