Conditional Versus Unconditional Caching

Configuring cache-control, max-age, and etags to handle if-none-match requests

by Joe Honton

In this episode Ivana optimizes her server's caching instructions for fast user access and reduced server load.

Caching is not a shiny new toy. It's been a part of HTTP since 1999. A strong understanding of how caching works is one of the core responsibilities of DevOps.

Ivana needed to configure her server to optimally handle the redesigned Rock City Vinyl website. She knew that inadvertent caching while it was still in development would just confuse things. She started out configuring the server to tell the browser not to store anything.

The HTTP cache-control instructions — she was using RWSERVE HTTP/2 Server — were set like this:

cache-control {
`*` *instructions='public, no-store'
}

She interactively followed her server's logs and confirmed that every request responded with status: 200 and cache-control: public, no-store as expected.

Caching resources

Ivana was aware that she would eventually have to configure caching of some sort. She started by experimenting with static resources which never change. She added instructions to the server's configuration to cache font files for a full year. Her reasoning was that font files are usually published in their final form, and rarely, if ever change. The server's config look like this:

cache-control {
`*.woff2` *instructions='public, max-age=31536000'
`*` *instructions='public, no-store'
}

Again she followed her server's logs. On the first request for a font file, the server responded with status: 200 and cache-control: public, max-age=31536000 as expected. When she refreshed the page, the browser's network monitor informed her that the font file was indeed retrieved from its local cache.

Ivana had successfully implemented caching.

Not Ready for Prime Time

Next, she decided that all of the website's photos and graphics would be good candidates for caching too. She added instructions to cache them for one month. Her reasoning here was that some of the images from Clarissa (in the art department), were not quite final, and might be changed before too long.

cache-control {
`*.jpeg` *instructions='public, max-age=2592000'
`*.png` *instructions='public, max-age=2592000'
`*.woff2` *instructions='public, max-age=31536000'
`*` *instructions='public, no-store'
}

So that worked as expected too. On the first request, the server responded with each image's contents. On every later request the browser used its locally cached copy of each image.

But on the very next day, Clarissa made a small change to the company logo, pushed it to the server, and tried to see how it looked on the staging website.

"Hey, Ivana," shouted Clarrisa from across the room. "Is something wrong with the staging server? I can't get my new logo to appear."

"Try hitting Ctrl Refresh," Ivana shot back.

"Nope."

"Oh right, you're on a Mac," she remembered, "Command Shift R."

"Got it!" Clarrisa was happy.

But Ivana was not. What if Clarissa had just pushed to the production website instead? There's no way to tell her customers to refresh the page.

She needed to rethink her strategy.

How about shortening the max-age to something more reasonable, like one day. That made more sense. That way new customers would get the latest logo right away, and returning customers would get it a bit later. That was a good compromise (for now). At any rate, everyone would be accessing the new version sometime within the next 24 hours.

Trouble in Rock City

A few weeks later, Rock City Vinyl's website was nearing completion. The CSS had been finalized. The HTML content was approved by the client. And the JavaScript routines were undergoing their final tests.

Ivana decided it was time to put the finishing touches on the caching rules. She settled on 60 minutes for the last three resource types. Here's how the staging server was now configured:

cache-control {
`*.html` *instructions='public, max-age=3600'
`*.css` *instructions='public, max-age=3600'
`*.js` *instructions='public, max-age=3600'
`*.jpeg` *instructions='public, max-age=86400'
`*.png` *instructions='public, max-age=86400'
`*.woff2` *instructions='public, max-age=31536000'
`*` *instructions='public, no-store'
}

Ivana was thinking through the problem, and considering what would happen if any of the programmatic files (HTML, CSS or JavaScript) changed. Since there were dependencies between them, it was important that all of them get served in a coherent state. Either all of the old versions should be served, or all of the new versions should be served.

Unfortunately, as she soon found out, it isn't sufficient to set the same max-age for these three. What happened was this. The shopping-cart.html page and the checkout.html page both use a function calcTotal() that is located in rock-city-vinyl.js.

One day the owner of Rock City Vinyl, Mop (that's his real name) remarked, "I'd like to have a way for customers to use coupon codes."

"OK, should be easy to do," Ivana replied.

So she modified the HTML and the JavaScript to accept a string value and to provide it to calcTotal(couponCode). After testing it locally, she pushed the updated rock-city-vinyl.js to the staging server.

Now, as it happened, the 60 minute caching timeout on Ivana's browser had not yet been reached for shopping-cart.html or rock-city-vinyl.js. This was because she had just recently accessed the old code, to be sure she knew how it worked.

"No problem," Ivana thought, "I'm still on the old code for a while longer."

On the other hand, the 60 minute caching timeout for checkout.html had long since passed (she had lasted tested it much earlier in the day). The new version of checkout.html was requested from the server. But the new version of calcTotal(couponCode) was not requested because the max-age for rock-city-vinyl.js had not yet been reached.

"овва!" Ivana shouted out, "Attempt to access undefined variable couponCode."

Ivana had the frightening thought that she'd have to implement some type of cache busting technique, or else completely turn off caching for programmatic files.

But Ivana was unaware that a solution already exists for problems like this. Ivana needed to implement conditional requests.

Conditional Versus Unconditional

The unconditional requests that she had been making occur when the browser and the server communicate without regard to the freshness or staleness of the cache. The browser simply examines the max-age value of the cache and only issues a new request when it has expired. The server dutifully returns the file's contents with a status: 200.

On the other hand, an HTTP conditional request is when the browser has a cached copy whose max-age has expired, so it communicates with the server using conditional request headers. When those conditions are met, the server responds with status: 304 (meaning "not modified") and a cache-control header containing a new max-age value, effectively renewing its lease. When those conditions are not met, the server responds using status: 200 with a response body that contains the newest version of the file's contents.

Conditions come in a few forms:

  1. The browser can send an if-modified-since header, on GET or HEAD requests, containing the time when the file was first retrieved.
  2. The browser can send an if-unmodified-since header, on POST, PUT or DELETE requests, containing the time when the file was first retrieved.
  3. The browser can send an if-none-match header, on GET or HEAD requests, containing the ETag returned by the server when it was first retrieved.
  4. The browser can send an if-match header, on POST, PUT or DELETE requests, containing the ETag returned by the server when it was first retrieved.
  5. The browser can send an if-range header, on GET requests (when the request specifies a range header — a request for part of a file), containing either a timestamp or an ETag for the partial file contents being requested.

This was a lot to contemplate. Ivana looked into the whole business about dates versus ETags and figured out that the use of conditionals based on dates was a legacy of HTTP's first attempt to solve the problem. ETags are the de facto solution in today's modern environment, so she concentrated her efforts there.

For her simple application, she was only concerned about caching GET requests, so she narrowed her research to the third type of conditional: if-none-match.

The RWSERVE docs told her that ETags could be sent by the server to the browser by enabling the etag module. So she started there:

server {
modules {
cache-control on
etag on
}
}

After that, when she issued a request for any resource, the server responded with the same cache-control headers as before, plus a new header, something like etag: "3c2a1a...bc83f5". The ETag value was computed by the server, and saved to the browser's cache. (In this example, the ETag is an SHA1 hash of the file's modification time and file size. This is how RWSERVE does it, but each server is free to decide for itself how to compute ETags.)

On subsequent requests, when the browser needs a resource that is in its cache, but the max-age value has expired, the browser sends an extra request header if-none-match: "3c2a1a...bc83f5".

Then, the server recomputes the Etag for the requested resource, and compares it to the value sent by the browser. If they match, the server replies with status: 304 and an empty body. If they don't match, the server replies with status: 200 plus the just-computed ETag value, and the full contents of the file in the response body.

Ivana used this new understanding to adjust her server's config one last time. She adjusted the max-age for programmatic files to a very low value — just 5 minutes. Her expectation was that new requests from new site visitors would still take the same amount of time. But conditional requests, from returning visitors, would only incur the overhead of a simple HTTP request/response (with no body, in most cases).

She thought this was an ideal solution, especially since she was using an HTTP/2 server, which kept connections open and used HPACK header compression. The result was very fast round-trips, and stay fresh resources.

The winning configuration became:

server {
modules {
cache-control on
etag on
}
cache-control {
`*.html` *instructions='public, max-age=300'
`*.css` *instructions='public, max-age=300'
`*.js` *instructions='public, max-age=300'
`*.jpeg` *instructions='public, max-age=86400'
`*.png` *instructions='public, max-age=86400'
`*.woff2` *instructions='public, max-age=31536000'
`*` *instructions='public, no-store'
}
}

No minifig characters were harmed in the production of this Tangled Web Services episode.

Follow the adventures of Antoní, Bjørne, Clarissa, Devin, Ernesto, Ivana, Ken and the gang as Tangled Web Services boldly goes where tech has gone before.

Conditional Versus Unconditional Caching — Configuring cache-control, max-age, and etags to handle if-none-match requests

🔎