According to HTTP Archive, among the top 300,000 sites, the browser can cache nearly half of all the downloaded responses. Undoubtedly this is a massive saving for repeat page views and visits. It reduces the time it takes the visitor to interact with the page (e.g., see the images or start using filters) and, at the same time, reduces the load on your server. Not to mention it reduces the cost of data transfer from your server (or CDN) to the end-user.

How Caching Works?

Suppose you open a webpage https://www.example.com and the server returns below HTML:

...
<link type="text/css" href="https://www.example.com/app.css" rel="stylesheet">
...
<!-- Rest of the HTML -->

When the browser parses this HTML, it identifies that a CSS resource needs to be loaded from https://www.example.com/app.css. The browser issues a request to the server, the server returns the image and tells the browser to cache it for 30 days.

GET app.css, no object found in local cache

Now, let's say you open the same page again after a few hours. The browser again parses the HTML and come across the resource at https://www.example.com/app.css. Since the browser has this particular resource available in its local cache, it won't even go to the server. The network request is avoided in this case, and the styles are applied very quickly.

GET app.css, object found in the local cache

Caching Static Assets On CDN

Now you understand how browser caches the resource, let's talk about taking it a step further. Let's say users across different physical locations access your website millions of times in a month. And every page load would load the same image logo-white.png (and many other static assets like JS and CSS). All these requests are coming to your server for the first time for each user. This unnecessarily strains your web server. To avoid this, you can use a Content Delivery Network (CDN) and cache the static resources on CDN nodes itself.

Resource loading without CDN

Resources are downloaded from the origin server

Resource loading with CDN

Resources are downloaded from the nearest CDN edge

Best Practices For Caching

Leveraging the cache is crucial, and there are few things to keep in mind while you set up your server, CDN, and application.

Choose The Right Cache Header Directive

Values of following HTTP headers in the response controls who can cache the response, under which conditions, and for how long.

  • Expires header
  • Last-modified header
  • Cache-control header (recommended)
The Cache-Control header is defined as part of the HTTP/1.1 specification, and it supersedes the other caching headers. All modern browsers support Cache-Control, and you can forget about the other two headers.

Set Optimal Cache Lifetime

The cache lifetime depends on what kind of resource you are trying to cache. For example, frequently updating content like avatars or script loaders can have a shorter cache lifetime. However, in almost all cases, static assets like JS, CSS, and images can be cached for a much longer duration.

You can use the table below to set the appropriate cache lifetime based on the type of resource:

Resource type Optimial cache-control value Description
JS, images, CSS public, max-age=15552000 This tells that the resource can be cached by the browser and any intermediary caches
for up to 6 months.

Note: Static assets can be safely cached for a longer duration, like six months or even one year.
HTML no-cache, no-store Don’t cache HTML on the browser so that you can quickly push updates to the client-side.

Note: You might want to cache HTML content on
CDN but never cache HTML content on intermediate proxies and browsers.
Different CDN has a different way of setting this cache privately without setting browser cache.

You can learn more about HTTP caching and different values for the cache-control header.

Ensure That The Server Adds A Validation Token (ETag)

Let's say you have specified a cache lifetime of 300 seconds, and now a page issues a new request for the same resource. Assuming 300 seconds have passed since the first request, the browser can't use the cached response. Now browser can issue a new request to the server. But, it would be inefficient because if the resource on the server hasn't changed, then it doesn't make sense to download the same resource we have in the local cache.

The validation token like ETag is here to solve this problem. The server generates this token, and it is typically a hash (or some other fingerprint) of the content, which means if the content changes, then this token change.

So the browser sends the value of this ETag in If-None-Match request header. The server checks this token against the latest resource. If the token hasn't changed, then a 304 - Not modified response is generated. 304 - Not modified response tells the browser that the resource in its local cache hasn't changed on the server, and its cache lifetime can be renewed for another 300 seconds. This saves time and bandwidth.

First download of the resource

First download of the resource

Subsequent If-None-Match request when object in local cache is expired

Subsequent If-None-Match request when object in local cache is expired
You need to ensure that the server is providing the ETag tokens by making sure the necessary flags are set. Use these sample server configuration to confirm if your server is configured correctly.

Embed fingerprints in the URLs of images, JS, CSS, and other static assets

The browser looks up the resources in its local cache based on the URL. You can force the client side to download the newer file by changing the URL of that resource. Even changing the query parameters is considered as changing the resource URL. Ever noticed file names like below?

<script src="/bundles/app.bundle.d587bbd6e38337f5accd.js" type="text/javascript"></script>
...
<div class="website-logo">
  <img src="https://www.example.com/logo-white_B43Kdsf1.png">
</div>
<div class="product-list">
  <img src="https://www.example.com/product1.jpg?v=93jdje93">
  <img src="https://www.example.com/product1.jpg?v=kdj39djd">
  ...
</div>
<!-- Rest of the HTML -->

In the above code, snippet B43Kdsf1, d587bbd6e38337f5accd, 93jdje93, and kdj39djd are essentially the fingerprint (or hash) of the content of the file. If you change the content, the fingerprint changes, hence the whole URL changes and browser's local cache for that resource is ignored.

You don't have to manually embed fingerprints in all references to static resources in your codebase. Based on your application and build tools, this can be automated.

One very popular tool WebPack can do this and much more for us. You can use long-term caching by embedding content hash in the file name using html-webpack-plugin:

plugins: [
  new HtmlWebpackPlugin({
    filename: 'index.[contenthash].html'
  })
]
Avoid embedding current timestamps in the file names because this forces the client-side to download a new file even if the content is the same. Fingerprint should be calculated based on the content of the file and should only change when the content changes.

You can also use gulp to automatic this using gulp-cache-bust module. Example setup:

var cachebust = require('gulp-cache-bust');
 
gulp.src('./dist/*/*.html')
    .pipe(cachebust({
        type: 'MD5'
    }))
    .pipe(gulp.dest('./dist'));

Aim for a higher hit ratio on CDN

A "cache hit" occurs when a file is requested from a CDN, and the CDN can fulfill that request from its cache. A cache miss is when the CDN cache does not contain the requested content.

Cache hit ratio is a measurement of how many requests a cache can fulfill successfully, compared to how many requests it received.

The formula for a cache hit ratio is:

Image source: https://www.cloudflare.com/learning/cdn/what-is-a-cache-hit-ratio/
When using a CDN, we should aim for a high cache hit ratio. When serving static assets using CDN, it is easy to get a cache hit ratio between 95-99% range. Anything below 80% for static assets delivery is considered bad.

Factors that can reduce cache hit ratio

  1. Use of inconsistent URLs - If you serve the same content on different URLs, then that content is fetched and stored multiple times. Also, note that URLs are case sensitive.

    For example, the following two URL point to the same resource.

    https://ik.imagekit.io/demo/medium_cafe_B1iTdD0C.jpg?tr=w-100,h-100
    https://ik.imagekit.io/demo/medium_cafe_B1iTdD0C.jpg?tr=h-100,w-100

    But the URLs are different, and hence, two different requests are issued to the server. And two different copies are maintained on the CDN cache.
  2. Use of timestamps in URLs - If you are embedding current timestamps in URLs, then this changes the URL and response won't be returned from the cache. For example - https://www.example.com/static/dist/js/app.bundle.dn238dj2.js?v=1578556395255
  3. A high number of image variations - If you have implemented responsive images and have too many variations for every possible DPR value and screen size. It could lead to a low cache hit ratio. If a user gets a tailored image dimension from your servers based on their screen width and DPR, then they might be the first user to request this specific image from your CDN layer.

    A rule of thumb is to check popular viewport sizes and device types from your Google Analytics and optimize your application for them.
  4. Using shorter cache lifetime - Static assets can be cached for a longer duration as long as you have a mechanism to push updates like embedding fingerprints in the URL. Setting a short cache duration increases the chances of a cache miss.

Use Vary Header Responsibly

By default, every CDN looks up objects in its cache based on the path and host header value. The Vary header tells any HTTP cache (intermediate proxies and CDN), which request header to take into account when trying to find the right object. This mechanism is called content negotiation and is widely used to serve WebP images in supported browsers and leveraging Brotli compression.

For example, if the client supports Brotli compression, then it adds br in the value of the Accept-Encoding request header, and the server can use Brotli compression. If the client-side doesn't support Brotli, then br won't be present in this header. In this case, the server can use gzip compression.

Since the response varies on the value of the Accept-Encoding header received from the client while sending the response, the server should add the following header to indicate the same.

Vary: Accept-Encoding

Or you can serve WebP images if the value of Accept header has a string webp. The server should add the following header in the image response:

Vary: Accept

This allows us to serve & cache different content on the same URL. However, you should use the Vary header responsibly as this can unnecessarily create multiple versions of the same resource in caches.

You should never vary responses based on User-Agent. For every unique value of User-Agent, a separate object is cached. This reduces the cache hit ratio.

Normalize request header, if possible. Most CDNs normalizes the Accept-Encoding header before sending it to the origin. For example, if we are only interested in the value of the Accept-Encoding header has the string br or not, then we don't need to cache separate copy of the resource for each unique value of Accept-Encoding header.

Cache Invalidation - Different Methods And Caveats

If you need to update the content, then there are a couple of ways to go about it. However, you should keep in the mind that:

  • Browser cache can't be purged unless you change the URL, which means you changed the resource. You will have to wait till the resource expires in the local cache.
  • Purging the resource on CDN doesn't guarantee that your visitor will see updated content because intermediate proxies (and browser) could still serve a cached response.

The above limitation leaves us with the following options:

Change the fingerprint in the resource URL

Always embed fingerprints in the URL of static assets and do not cache HTML on the browser. This will allow you to push changes quickly and, at the same time, leverage long term cache. By changing the fingerprint in URL, we are essentially changing the URL of resource and forcing the client-side to download a new file.

Wait till resource expires in the local cache

If you have chosen your cache policy wisely based on how often you change the content, then you don't need to invalidate the resource from caches at all. Just wait till the resources expire in caches.

Use Service Workers To Manage Cache

You can cache files using service workers to manage local cache using the Cache interface.

This is useful when caching content, which often changes such as avatars or marketing banner images. However, you are responsible for implementing how your script (service worker) handles updates to the cache. All updates to items in the cache must be explicitly requested; items will not expire and must be deleted.

You can put items in the cache on network request:

self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.open('mysite-cache').then(function(cache) {
      return cache.match(event.request).then(function (response) {
        return response || fetch(event.request).then(function(response) {
          cache.put(event.request, response.clone());
          return response;
        });
      });
    })
  );
});

And then later serve it from the cache:

self.addEventListener('fetch', function(event) {
  event.respondWith(caches.match(event.request));
});

Purge From CDN

If you need to purge the cache from CDN and don't have any other option, most CDN providers have this option. You can integrate their cache purge API in your CMS so that if a resource is changed on the same URL, a cache purge request is submitted on the CDN.

Conclusion

Here is what you need to remember while caching static resources on CDN or local cache server:

  1. Use Cache-control HTTP directive to control who can cache the response, under which conditions, and for how long.
  2. Configure your server or application to send validation token Etag.
  3. Do not cache HTML in the browser. Always set cache-control: no-store, no-cache before sending HTML response to the client-side.
  4. Embed fingerprints in the URL of static resources like image, JS, CSS, and font files.
  5. Safely cache static resources, i.e., images, JS, CSS, font files for a longer duration like six months.
  6. Avoid embedding timestamps in URLs as this could quickly increase the variations of the same content on a local cache (and CDN), which will ultimately result in a lower cache hit ratio.
  7. Use Vary header responsibly. Avoid using Vary: User-agent in the response header.
  8. Consider implementing CDN purge API in your CMS if you need to purge content from the cache in an automated way.