← Go Back

Getting your HTTP Cache-Control headers right

Caching plays a fundamental role in performance. What’s quicker than a fast request? No request. Getting your caching strategy right can make a huge difference to your users - both repeat and first time.

“There are only two hard things in Computer Science: cache invalidation and naming things.” (Phil Karlton)

25 years later, this well-known saying still holds true and while I cannot help you with the naming, I want to share what I know about caching to make it easier for you to decide on a caching strategy. Disclaimer: The scope of this article isn’t to serve as a comprehensive guide to all possible caching strategies and how they work - there’s already plenty of content like that around. My goal is to provide you with a reference which you could use to answer the question, “How should I cache this resource?”

What is caching?

A cache is a store where you could put a resource. This could be done to avoid having to recalculate an expensive computation or to place the content closer to the user. In websites, this is dictated by the Cache-Control header; which is defined in the HTTP/1.1 specification.

Note: The Expires header is no longer needed as its behavior is covered by the Cache-Control header.

Private and shared caches

In addition to caching in your browser, if your content is behind a CDN then your cache-control headers influence how the CDN caches your content on the edge. Browser cache is referred to as a private cache, while a CDN cache is referred to as a shared cache. The cacheability of a resource is set using the directives below:

  • public - Any cache may store the response, including a CDN.
  • private - The response is intended for a single user and should only be stored by the browser cache.
  • no-store - Should not be stored on any cache.

Demo

To test the different cache-control headers, I have created a repository containing a simple NodeJS server. Please feel free to explore the source code on GitHub and run the application locally.

Static assets

Static assets refer to files whose contents do not change and are not generated on request. This includes scripts, stylesheets, and images. As these files are generated at build time, most modern build tools can assign a unique hash to each file. This hash serves as a unique fingerprint for each version of the file and is included in the resource’s URL. This could be in the form of a query string parameter (e.g. app.js?v=c4d27698), or filename (e.g. index-0e6a410d.js). Having a unique URL means that you can cache these assets for a very long time, even up to one year.

cache-control: max-age=31536000

When the website is redeployed and the contents of one of the source files have changed, then it will be assigned a new URL and create a new cache entry. If the files are served through a CDN, then the CDN will cache this file. Most CDNs will purge the old files on each redeployment.

  • Freshness - Guaranteed.
  • Performance - First user to request the file will receive it from the origin server. Every other user will receive a cached copy.

Caching the HTML page

One of the major headaches with caching is your HTML page. Generally speaking, your HTML page will make requests for other resources which are needed to render the page, such as CSS, images and JavaScript files. Whenever your website is redeployed, it will update the references to the static assets within the HTML document.

<link rel="stylesheet" href="/style.v1.css" />

becomes

<link rel="stylesheet" href="/style.v2.css" />

So if the HTML document is cached, users with a cached copy will request the old stylesheet, style.v1.css; which no longer exists on the origin server. So while style.v1.css is likely to be cached on the user’s browser because of its long expiry, it is possible that some users encounter a broken website too. If the cached resource style.v1.css is removed from the cache before the cached HTML resource, the user will see a broken layout.

To complicate matters, your HTML may also contain server-side rendered content retrieved from a CMS. If the content is updated on the CMS, the cached HTML is now stale.

Scenarios

When deciding your caching strategy, it is usually a trade-off between performance and freshness. Instead of explaining the different cache-control directives and their applications, I will be describing some real-life scenarios I worked with and how I would set my cache-control headers for that type of website.

Blog

If you are looking to cache your blog, the chances are that the content is not time-sensitive and you would prioritize performance over freshness. Users are more likely to go directly to the article page instead of navigating through the website. If they revisit your blog, they are likely to return when a new article is published - that is after the cache has expired. The CSS and JavaScript files probably do not change that often either.

cache-control: public, max-age=600, stale-while-revalidate=60

The blog you’re reading uses a public, max-age, and stale-while-revalidate combination. By applying a max-age of 600 (10 minutes), you can cache the page for the duration of the user’s session. If they leave the page and return within 10 minutes, it would load instantly.

The public directive states that the resource may be cached on both private and shared caches. Therefore if the resource has expired in the user’s browser cache, it would request it from the CDN. The CDN may have the resource cached and serve it instantly. If it has expired on the CDN, then it would fetch it from the origin and update the caches.

Using stale-while-revalidate means that the resource can be served stale for an additional 60 seconds after it has expired while it is being revalidated.

In my specific case I am able to cache the HTML file rather aggressively as I ship very little JavaScript - none of which is critical to the user experience - and the critical CSS is inlined in the HTML file. Depending on how important your subresources are to the user experience, you may want to cache for a shorter period. If your blog does not work at all without JavaScript, then you might want to reduce the max-age to avoid the race condition described earlier.

  • Freshness - Users may receive a worst-case 11-minute old response.
  • Performance - The first user to request the resource will wait for it to be fetched from the origin. If the user returns within 11 minutes, they will receive a cached response. If they return after 11 minutes, they will be served a cached copy from the CDN. If no users visit the website for 11 minutes, the first user to visit the website will wait for the resource to be fetched from the origin.

News website

A news website is updated frequently, with new articles being added to the home page and the content of some articles updated several times per day.

cache-control: public, max-age=30, stale-while-revalidate=30, stale-if-error=600

By applying a combination of max-age, stale-while-revalidate, and stale-if-error you can always serve content quickly while minimizing the risk of stale data. Resources will be cached on shared or private caches for 30 seconds. When the resource expires, the cache will serve the stale data for another 30 seconds. The stale-if-error directive indicates that if the origin server does not respond when revalidating the resource, the cache may serve the stale data for another 10 minutes.

Note: The stale-if-error directive isn’t widely supported on the browser but is supported on most CDNs.

  • Freshness - In the worst case, the user may receive a 1-minute old response. The max-age and stale-while-revalidate values may be adjusted to calibrate this worst-case scenario.
  • Performance - The first user to request the resource will wait for it to be fetched from the origin. If the same user or any other user revisits the page within 30 seconds, they would get a cached response. If they visit the page beyond 30 seconds but within 60 seconds, then they would get an instant (stale) response while the cache is revalidated. If they revisit the page beyond 60 seconds, then the resource is fetched from the CDN. If no user visits the page for 1 minute, the next user will fetch the resource from the origin.

Live event

If your website is showing a live event and content is updated frequently, you want the data to be as fresh as possible. An example of a live event may be a sports match showing real-time scores or an election results page.

cache-control: public, no-cache

Setting no-cache indicates that a cache must not be used for a subsequent request without revalidating it with the origin server. This is the equivalent of must-revalidate, max-age=0. Once again, setting public will enable your CDN to cache the resource and avoid hitting your origin server for each user request.

  • Freshness - Users will always receive a fresh resource.
  • Performance - Each request will incur a network call. If the resource has not changed, then the payload would only include the response headers.

Logged-in pages

Any page or resource which is only available for a logged-in user or contains data specific to a logged-in session should not be cached.

cache-control: no-store
  • Freshness - Users will always receive a fresh resource.
  • Performance - Each request will be fetched from the origin.

Conclusion

HTTP Cache-Control headers remain a cornerstone for good performance. When choosing a caching strategy, take your time to consider the following:

  • Not all resources are equal.
  • Measure the performance gains a user would benefit from if you cache a resource.
  • Can you get away with serving stale content?
  • Identify how likely your cached resources may become out of sync and what your users would experience in that eventuality.

There is a lot of content explaining how the different cache-control directives work. Below are some of my favorite resources on the topic.

© 2022 Kevin Farrugia