An HTTP Caching Strategy for Static Assets: Configuring the Server

This post is the third in a four-part series:

  1. An HTTP Caching Strategy for Static Assets: The Problem
  2. An HTTP Caching Strategy for Static Assets: Generating Static Assets
  3. An HTTP Caching Strategy for Static Assets: Configuring the Server

Now that we’re generating files according to plan, we need to start serving them with the appropriate cache headers from our server.

For Launch, we use NGINX for our HTTP server. Let’s take a look at our NGINX configuration (I’ve trimmed it down to the important parts):

server {
...
location ~ \.cache-[a-z0-9]+\. {
etag off;
add_header Cache-Control "public,max-age=31536000,immutable";
}
location ~ .+\..+ {
add_header Cache-Control "no-cache";
}
...
}

Now let’s break it down.

server {
...
}

This is a standard NGINX server block. We’ll place our configuration for the server inside of it.

Serving content-addressed files

location ~ \.cache-[a-z0-9]+\. {
etag off;
add_header Cache-Control "public,max-age=31536000,immutable";
}

This is a location block. The ~ means we’re going to match the request URI to a regular expression, which then follows. The regular expression is\.cache-[a-z0-9]+\. which tests to see if somewhere in the URI there is a period, then cache-, then one or more lowercase alphanumeric characters, then another period.

If the URI matches this pattern, we can assume that the file we’re returning is intended to be cached on the user’s browser forever, so we need to send the appropriate headers.

The etag off; directive tells the server to not send ETags. Don’t know what ETags are? Fret not; I’ll describe them later. All you need to know for now is that they‘re not useful if the user is going to cache the file forever. Since they won’t be useful in this particular case, they would just waste bandwidth, so we turn them off.

We then add a Cache-Control HTTP header, which tells the browser if and how to cache the file. The directives we’ve chosen behave as follows:

  • public When the user’s browser makes a request for a file, that request goes through many servers before reaching ours (perhaps owned by the user’s employer, ISP, etc.). Some of those servers can be proxies and have the ability to cache files coming back from the server. When we use the public directive, we’re telling these interim, “public” proxy servers that they can go ahead and cache our files. If a user were to request a file through a proxy server that already had the file cached, the proxy server could immediately return the cached file rather than forwarding the request to our server. We’re fine with that.
  • max-age=31536000 This indicates how long, in seconds relative to the time of the request, the file may be considered “fresh.” In other words, how long can the user’s browser (or even proxy servers) keep using the file directly from its cache without checking with our server to see if there’s a newer version. I’ll save you from pulling out your calculator — 3153600 seconds is 1 year (except for leap years and such, but meh). Ultimately, we want the file to be cached forever, so why don’t we put 10 years or 1,000 years? Because the HTTP spec says we shouldn’t use anything over a year, and we’re good citizens, so we don’t.
  • immutable The backstory behind this one is probably the most interesting. The TL;DR version is that Facebook was using a content-addressable caching strategy like we’re outlining here, but they noticed that they were repeatedly serving the same files to some of their users within a short timespan. Why weren’t these users’ browsers re-using the files in their cache? Well, because the users kept hitting the refresh button over and over, hoping to see new status updates roll in from their friends. Browsers were purposefully designed in such a way that when the user hits the refresh button, they would disregard the files in the cache and always request them from the server anew. Facebook took up their conundrum with browser vendors. Short story shorter: Chrome changed the behavior of the refresh button. Meanwhile, Firefox was worried about changing its behavior, so Firefox decided to add a new cache directive called immutable that basically lets the server tell the browser, “No, seriously, this file is never going to change. Just use it from cache. Srsly. Stop asking. STAHP!” And here we are.

Serving non-content-addressed files

If the URI doesn’t match the regular expression pattern described above, NGINX then processes our next location block:

location ~ .+\..+ {
add_header Cache-Control "no-cache";
}

Similar to our last location block, this one checks if the URI matches a regular expression. This regular expression, however, will match if the URI has some characters, then a period, then some more characters. This is our rudimentary test for a file rather than a directory. In our caching strategy, this will match our index.html file. We could have just made it match only index.html, but it’s possible we have some other files crop up that aren’t feasible to make content-addressable or we forgot to do so or whatever. For this reason, we make this a catch-all for any file URI that wasn’t processed by the previous location block.

If you recall, the content of these particular files will change over time and it’s very important that the user is always using the latest. For this, we leverage the Cache-Control header with the no-cache directive.

Although it may seem counter-intuitive, no-cache doesn’t mean “don’t cache.” Instead, it means, “Go ahead and cache the file, but you must not use the file from cache until you’ve asked the server if you have the latest.”

This is where ETags (short for Entity Tags) come into play. An ETag is a string the web server generates that uniquely identifies a file. An example ETag might look something like 5a85ec50-17f, but the format and how it is generated varies depending on the server software generating the ETag. When a file is requested over HTTP, the server can include an ETag with the response. As long as the file never changes on the server, the same ETag will be returned along with every response to every request for that specific file. As soon as someone changes the file on the server, however, a new ETag will be generated and sent on responses.

Let’s see how this works. The user’s browser makes a request for index.html. The server returns index.html along with the Cache-Control header with the value of no-cache and an ETag header with the value of 5a85ec50-17f. The browser stores the index.html file, along with the accompanying ETag, into its cache. The next time the user visits the website, the browser will ask the server for index.html again. Remember, no-cache means the browser must check with the server before using a file from cache. In the request for index.html, the browser will include the ETag value that it previously received from the server: 5a85ec50-17f.

At this point, the server will compare the ETag it received in the request with the ETag for the index.html that the server currently holds. There are two scenarios that can happen here:

  1. If the ETags match, the server can conclude that the browser has the latest copy of index.html. There is no need to send the file back to the browser (it would be wasted bandwidth), so it returns a 304 Not Modified HTTP response instead. The browsers receives the response and uses the copy of index.html it has in its cache.
  2. If the ETags do not match, the server can conclude that the browser has an outdated copy of index.html. The server will then return the latest copy of index.html along with the latest ETag. The browser receives the response and replaces the outdated copy of index.html it had in its cache with the new copy it just received from the server. It also updates the cached ETag.

Supporting clustered environments

It’s important to understand that ETags are generated differently depending on the HTTP server software you are running. In our case, we are using NGINX, which generates ETags based on the the file’s last modified timestamp and the file’s content length.

In a clustered environment, HTTP requests are often load balanced across multiple servers. Depending on how you deploy your files to the various servers in your environment, the last modified timestamps for your files may differ from one server to the next. This is bad, since the ETag for a given file would therefore also differ between servers. This means if a user makes a request for index.html from server A and then later makes another request for index.html from server B and includes the ETag it received from server A, server B will see the ETag it holds as being different than the ETag that originated from server A. As a result, server B will send back a 200 status code with the file content, when really the file on server B is no different than the file on server A. The browser should have been able to use the file it already had in its cache.

In our case, we use Docker for deployment. Our Docker image includes the files the HTTP server will be serving. As long as the files are not modified by anything inside the running Docker container on each server, the last modified timestamps will be consistent across servers and therefore the ETags will be consistent as well.