Are Your Cache-Control Directives Doing What They Are Supposed to Do?

8 min read

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 to Expires, 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 to max-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 to must-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.