CloudFront Functions for an Efficient Cache Policy

Using CloudFront functions to apply some clever and sensible Cache-Control headers to a fully static Next.js app.

Kevin Wang


This site itself is a static Next.js app. It's a bunch a static assets, living in AWS S3 and served through CloudFront. CloudFront does a great job at caching resources but when it came time for a Lighthouse audit, this one — Serve static assets with an efficient cache policy — was failing for me.

Why? All my assets were missing a Cache-Control header.

Lighthouse audit before cache control headers

Passing the audit

Given the serverless nature of this site, there were two spots where I could apply cache-control headers — S3 and CloudFront Functions.

Site Architecture Overview

S3 Cache-Control Headers

While you can set Cache-Control headers via item metadata in S3, you probably don't want to do this.

Initially, I set the Cache-Control header to public,max-age=0,must-revalidate as a baseline value that I intended to overwrite for specific routes, later down the response journey (specifically in a CloudFront function).

However, this resulted in a minor bug. The max-age=0 value returned by S3, is also used by CloudFront itself to determine how long to cache a certain resource, and in this case, it effectively told CloudFront, "always refresh me".

I realized this after deploying some changes, and then cURL'ing my website URL multiple times. I was expecting a Hit from cloudfront value for the x-cache header...

curl --head https://thekevinwang.com/

...but instead I received RefreshHit from cloudfront every time. Documentation around the difference between "RefreshHit" & "Hit" pretty much doesn't exist, but this forum post was what I referenced to conclude that the previous behavior was undesirable.

CloudFront Functions

CloudFront functions were where I ended up setting my header values.

CloudFront Functions were introduced around May 2021 and serve 4 documented use cases:

  • Cache-key manipulations and normalization
  • URL rewrites and redirects
  • HTTP header manipulation 👈👈👈
  • Access authorization

The particular kind of function I used was the Viewer Response — it's triggered right before a response is returned to a requesting client.

I wrote two simple CloudFront Functions to modify the cache-control header to public,max-age=31536000,immutable for routes that I wanted to cache, and public,max-age=0,must-revalidate for routes that I didn't want to be cached. I used these in conjunction with CloudFront Behaviors to apply the functions to the proper routes.

Cache static assets; Don't cache HTML

I didn't want the html documents to be cached, or else an end-users' browser might be loading an older, cached version of the site when a newer deployment is actually available.

I did want everything elsejs, css, json, etc. — to be cached because the html files have references to all the static assets that they need. Next.js is smart about generating unique hashed filenames on each new build of your project.

Lighthouse Success

Going back to the original failing Lighthouse audit, this is the result after applying the necessary cache-control headers.

Lighthouse audit after cache control headers

AWS CDK TypeScript Snippet

Here's a glimpse of what my CDK code for provisioning two CloudFront Functions, and adding multiple CloudFront behaviors looks like.

Gotchas

No let or const in CloudFront Functions

This is a strange, easy to resolve error.

"const" is not allowed in CloudFront functions