> Blog post by Lokesh Saini
> Canonical (HTML): https://www.lokeshsaini.com/blog/immutable-header-froze-my-page

I deployed a fix. The page still looked broken. I deployed it again. Still broken. I opened an incognito window and the fix was there, perfectly visible. My regular browser, used by anyone who had ever visited the page, would not show it.

The culprit was a single header I had added weeks earlier while optimizing static assets.

## The Header That Seemed Harmless

The goal was straightforward: tell browsers to cache images for a long time so repeat visits load fast. I added this to my Next.js config:

```javascript
async headers() {
  return [
    {
      source: "/case-studies/:path*",
      headers: [
        {
          key: "Cache-Control",
          value: "public, max-age=31536000, immutable",
        },
      ],
    },
  ];
}
```

One year max-age. `immutable` to tell the browser not to bother revalidating. This is the standard recipe for fingerprinted assets, and it works perfectly when applied correctly.

The trap: `:path*` matches zero or more path segments. I meant it to match `/case-studies/my-project/hero.png`. It also matches `/case-studies` with nothing after it. The listing page, an HTML document that changes every time I add a project, now had a one-year immutable lifetime in every browser that had visited it.

## Why Deploying Fixes Did Nothing

`immutable` is a strong directive. It tells the browser: do not even check with the server. The content at this URL will never change for the duration of max-age. Reload does not help. The browser skips the network entirely.

The server was correct on every fresh load. Any CI check, any curl request, any browser that had never visited the page would see the updated content immediately. But anyone who had a cached copy, including me testing on my own machine, was reading a frozen snapshot from whenever they first visited.

There is no error in the console. The response code is 200. The browser fetches nothing because it sees no reason to fetch anything. From the browser's perspective, the page is fresh.

This is what makes the bug so frustrating to track down. You look at the server logs: every request hits the latest version. You deploy again, check the deployment, see the new content. Everything on the server side is correct. The problem is entirely inside the browser, invisible to any server-side tooling.

## The Failure Shape

A stale-looking bug that reproduces for some people and not others, where fresh browser profiles always see the fix, is almost certainly a caching problem. The bug is invisible in automated testing because CI always hits a cold cache.

The people most likely to be affected are also the people you most want to impress: anyone who has visited your site before. New visitors, using a clean session, see the correct version. Return visitors, the ones who liked your site enough to come back, see the frozen version.

Hard refresh (Cmd+Shift+R or Ctrl+Shift+R) bypasses the cache and loads the current version. But you cannot tell your users to hard refresh. Most people have never done it. It is not a fix, it is a workaround that only works for people who know to try it.

## Diagnosing It

The tool that finally made it obvious was the Network tab in DevTools, filtered to the document request. I looked at the response headers for the HTML page itself, not the images. There it was:

```text
cache-control: public, max-age=31536000, immutable
```

That header has no business being on an HTML document. HTML pages at non-fingerprinted URLs cannot keep the promise `immutable` makes. The URL `/case-studies` does not change when the content changes, so the browser has no way to know a new version exists.

For assets, the fingerprinting pattern solves this. Build tools append a content hash to the filename: `hero.abc123.png`. If the image changes, the hash changes, the URL changes, and the browser treats it as a new resource with no cached copy. The URL changing is what makes `immutable` safe.

An HTML page at a stable URL has no equivalent mechanism. You cannot change the URL every time you update the content without breaking every link, bookmark, and indexed URL pointing to it. So HTML pages must always be revalidatable. `Cache-Control: no-cache` or a short `max-age` with `must-revalidate` are appropriate. `immutable` is not.

## The Fix

Scope the immutable rule to actual file extensions, not a path prefix:

```javascript
async headers() {
  return [
    {
      source: "/:path*\\.(png|jpg|jpeg|webp|avif|gif|svg|webm|mp4|woff2)",
      headers: [
        {
          key: "Cache-Control",
          value: "public, max-age=31536000, immutable",
        },
      ],
    },
  ];
}
```

File extension matching cannot accidentally catch HTML pages. A `.png` URL either points to an image or it does not exist. There is no ambiguity.

This also makes the intent legible. A path prefix like `/case-studies/:path*` says "content under this directory." A file extension pattern says "files of this type." If your intent is "cache images forever," write a rule that means "cache images forever," not a rule that means "cache things in this folder forever."

## After Any Header Change: Check the Document

The verification step I now do after touching any cache configuration: open DevTools, go to the Network tab, do a hard refresh, click the document request (the HTML page itself, not the JS or images), and read its response headers.

This takes about ten seconds. It would have saved me from this problem entirely.

Pattern-based routing rules in Next.js, Vercel config, or nginx are easy to write in ways that match more than you intended. The pattern is the thing to audit, not the intent. Enumerate what the source pattern actually matches before deploying it, and always check the document response headers separately from the asset headers. They will often tell different stories.
