DEV Community

Daniel Pertu
Daniel Pertu

Posted on

The only path on our site with a one year immutable cache header is a path that serves nothing

There is a block in our next.config.mjs that has been there since June. It looks like the most ordinary thing in the file.

{
  // Cache static assets aggressively
  source: '/static/:path*',
  headers: [
    {
      key: 'Cache-Control',
      value: 'public, max-age=31536000, immutable'
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

A year, immutable, on static assets. Everybody has written that rule. Here is what it actually does on the deployed site:

$ curl -sI https://pub-trivia.app/static/app.css | grep -iE 'HTTP|cache-control|location'
HTTP/2 307
cache-control: public, max-age=31536000, immutable
location: /login?next=%2Fstatic%2Fapp.css
Enter fullscreen mode Exit fullscreen mode

A redirect to our sign-in page, and a browser has been told it may keep that answer for a year without asking again.

Three separate things have to be true for that

There is no /static directory. public/ holds six files, and Next serves public/ at the root of the site. public/logo.svg is at /logo.svg. Nothing has ever been at /static/anything.

The framework's own assets are somewhere else. Next.js build output is served from /_next/static/, which is a different path, and it already carries the header this rule was reaching for:

$ curl -sI https://pub-trivia.app/_next/static/chunks/<hash>.css | grep -i cache-control
cache-control: public,max-age=31536000,immutable
Enter fullscreen mode Exit fullscreen mode

That one is the framework's default. It was never ours to set.

headers() matches paths, not files. The rule is a routing-layer pattern. It does not check whether anything is served there, it stamps the header on whatever response comes back for a matching URL. And on this site the response for an unknown path is a redirect, because the auth gate is a prefix allowlist and /static is not on it.

So the configuration, the routing layer and the auth gate each did exactly what they say they do, and the result is an immutable year on the words "go and sign in".

The real static files get max-age=0

The file that this rule was presumably meant to cover:

$ curl -sI https://pub-trivia.app/logo.svg | grep -i cache-control
cache-control: public, max-age=0, must-revalidate
Enter fullscreen mode Exit fullscreen mode

Zero. The logo is in every header on every page of a 72 page site and it is revalidated on every navigation that is not served from the back/forward cache. That is the framework default for public/, it is a reasonable default, and it is also the exact thing the rule in the config was trying to improve, two directories away from where it was pointed.

The site has, in total:

path cache-control what is there
/_next/static/* one year, immutable build output
/logo.svg and friends max-age=0, must-revalidate the files in public/
/static/* one year, immutable nothing, then a 307

One rule, one file, three rows, and the rule is on the only row with no content behind it.

How much does this actually cost

Close to nothing, today, which is why it survived four months and a content build-out.

Nothing on the site links to /static/. No page, no stylesheet, no script, no sitemap entry. The only way to request one of those URLs is to type it, and the only visitors who do that are scanners looking for a leftover build directory. They get told to log in, and they are welcome to cache it.

It becomes a real bug the first time somebody creates public/static/ and puts a file in it. From then on the auth gate still redirects (because the allowlist is a list of route prefixes, not a check for files on disk), every one of those redirects is still immutable for a year, and a browser that hit the URL once during that window has pinned the wrong answer in a way a deploy cannot clear. Renaming the file afterwards does not rescue the browsers that already asked for the old URL, because what they cached was not the file, it was the instruction to go somewhere else.

What we are doing about it

Deleting the block, not fixing it. There is no /static, and if there is ever a reason to serve long-lived assets from our own path, the rule has to be written at the same time as the path and the allowlist entry, by somebody who can check all three.

The general shape, which is the part worth taking away: a headers() rule is a claim about URLs, and your framework may well be serving those URLs from a place you are not thinking about. The cheapest way to find out is to curl a URL that definitely does not exist under the pattern, and read what comes back.

curl -sI https://your-site/static/definitely-not-a-real-file.css
Enter fullscreen mode Exit fullscreen mode

If you would like to see the pattern in the wild, run that against ours: the pricing page and the tools hub are ordinary pages that show the normal header set, and /static/anything next to them shows the one that is wrong. The free tools themselves are worth a look while you are there, since caching is most of why they are fast.

Top comments (0)