Hugo’s secret caching superpowers

This blog isn’t all that complex. A bit of CSS, a bit of JS (only for fun; press G), some fonts, and a whole bunch of HTML. No images to speak of, at least as of this writing. It’s not exactly slow to load, even with empty caches across the board.

But until recently the best case was only somewhat better than the worst. All static responses included Amplify’s default Cache-Control header: public, max-age=0, s-maxage=31536000. That is all of the HTML, CSS, JavaScript and images.

To understand what that means, lets break it down:

The public directive permits the response to be stored in a shared cache, such as on a CDN. Nothing wrong with that.

The max-age directive informs the freshness algorithm. If the sum of the response’s age (as provided by the Age header) and the time since the response was stored exceeds max-age, the browser will send another request. None of that is really relevant here because max-age is set to 0, so the browser will send a request for the resource every time.

There is some good news though: s-maxage instructs any shared cache to override the value of max-age for its own purposes. That means CloudFront (which Amplify uses) will only update its own cache when a response becomes more than a year old.

The browser’s cache will never be fresh so it will request a new response from the server every time, but CloudFront’s cache is good for one year so it will only request an updated version of the resource from the origin server annually. This might seem scary if you update your site more than once a year like some kind of try-hard, but Amplify will invalidate the CloudFront cache on every single deployment so CloudFront will reach back to the origin server for a fresh response for every resource after a deployment.

This is a pretty reasonable default for HTML responses. I expect them to change, so the browser should check for newer content pretty often. On the other hand images and fonts don’t change very often. The same goes for CSS and JavaScript to a lesser extent. It would be nice if we could do even better for these resources.

And I can. By adding a customHttp.yml file, I can instruct Amplify to return different Cache-Control headers:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
customHeaders:
  - pattern: '**/*.css'
    headers:
    - key: 'Cache-Control'
      value: 'public,max-age=31536000,immutable'
  - pattern: '**/*.js'
    headers:
    - key: 'Cache-Control'
      value: 'public,max-age=31536000,immutable'
  - pattern: '**/*.woff2'
    headers:
    - key: 'Cache-Control'
      value: 'public,max-age=31536000,immutable'
  - pattern: '**/*.jpg'
    headers:
    - key: 'Cache-Control'
      value: 'public,max-age=31536000,immutable'

I’ve already discussed public and max-age, and you probably recognize a year in seconds by now, but immutable is a new directive for us. The immutable directive indicates that a resource should not be revalidated while it is fresh. So I’m essentially telling the browser to hang on to the response for a year, and don’t bother checking for updates. In this case, the browser will load the response from the cache without checking the server for changes. No network traffic at all.

But let’s revisit that concern we had before: what happens when you do need to update a resource? That’s where Hugo comes in to save the day.

The good part

Aside from changing infrequently, there is another characteristic of JavaScript, CSS, images and fonts that we can take advantage of: they are not primary resources, they are dependencies.

The URLs for our HTML pages matter. We share them with the world, and everyone expects a blog post to stay in the same place even if it is updated later on. We don’t care so much about the URLs for our other resources because we usually only request them by way of references in HTML pages.

If we version our resources in the URL the browser will skip the cache for an updated version. More accurately, the cached response will be irrelevant, because as far as the caching system knows, we are requesting a whole new resource. The challenge becomes about managing the versioning. A hashing function like SHA 256 is the perfect solution here because the hash will be different every time the content of the resource changes, and the same content will produce the same hash every time.

Hugo has a feature that is perfectly suited to make this a light lift. Hugo Pipes is a suite of functions used to process assets. It can do things like resize images, process SCSS into CSS, minify and combine files, and much more.

The primary function we’ll use here is Fingerprint. Fingerprint will hash the file, and update the URL for the asset to include the hash.

1
2
{{- $styleCSS := resources.Get "style.css" | resources.Fingerprint "sha256" -}}
<link href="{{$styleCSS.Permalink}}" rel="stylesheet">

The call to resources.Get loads a file called “style.css” from the assets directory. That gets passed to resources.Fingerprint, which creates a SHA 256 hash. Finally, we output the resource’s Permalink in a link tag.

The same approach works for fonts, images, JavaScript and any other assets.

Let’s take it a step further. CSS files have an @import directive that loads another CSS file. We want to bust the cache for that imported resources as well, so we have to treat CSS files like a Go template. Hugo Pipes has a function called ExecuteAsTemplate that can help. Let’s update the previous example:

1
2
{{- $styleCSS := resources.Get "style.css" | resources.ExecuteAsTemplate "style.css" . | resources.Fingerprint "sha256" -}}
<link href="{{$styleCSS.Permalink}}" rel="stylesheet">

The "style.css" in this function call refers to what the name of the file should be after processing the resource as a template, and the . passes in the current context. Now, let’s take a look at what might be in style.css:

1
2
3
4
5
6
7
8
9
{{- $layoutCSS := resources.Get "layout.css" | resources.ExecuteAsTemplate "layout.css" . | resources.Fingerprint "sha256" -}}
@import url({{$layoutCSS.Permalink}});

{{- $montserrat := resources.Get "montserrat.woff2" | resources.Fingerprint "sha256" -}}

@font-face {
    font-family: "Montserrat";
    src: url({{$montserrat.Permalink}});
}

We use the same chain of pipes for every CSS file we load, whether we load it from HTML or another CSS file. Take a closer look at the font example though: fonts can’t be processed as a Go template, so we have to skip the ExecuteAsTemplate step. The same would apply for images and other resources that include binary data.

JavaScript is a bit more complicated

JavaScript that is referenced via a script tag can use the same pipe chain as fonts or images. If your JavaScript file imports other JavaScript modules you can use Hugo’s js.Build function to bundle your JavaScript into a single file using ESBuild behind the scenes.

1
2
{{- $mainJS := resources.Get "main.js" | js.Build | resources.Fingerprint "sha256" -}}
<script src="{{$mainJS.Permalink}}"></script>

If you prefer to skip the bundler you can use an import map instead. An import map tells the browser where to find the modules that your JavaScript code imports:

1
2
3
4
5
6
7
8
{{- $mathJS := resources.Get "math.js" | resources.Fingerprint "sha256" -}}
<script type="importmap">
{
    "imports": {
        "mathjs": "{{$mathJS.Permalink}}"
    }
}
</script>

This approach requires your JavaScript modules to be in the assets directory, and for you to add every import to the map by hand. If you have so many JavaScript dependencies that this becomes unwieldy, I recommend looking for other tooling to fingerprint your JavaScript dependencies (or better yet, rethink your dependencies).

Wrapping up

With Hugo and Amplify I started off in a pretty good place on my blog. I tend to load a page on an empty cache in less than 150ms, which is nothing to sneeze at. But Sean Coates is my friend and colleague, and in the spirit of friendly competition I wanted to see if I could accomplish the same results with Hugo that he has gotten on client projects for years. A draft of this post loads in 35.2ms for me with a warm cache, and yeah, that’ll do it. Comments and feedback are welcome on Mastodon.