All articles · Performance

Cache Headers That Work: Hashed Assets, immutable and Revalidation

Cover with the article title next to a stopwatch over a stack of boxes, in green on a dark background

HTTP caching is one of the few performance techniques that makes repeat visits nearly free. A returning visitor whose browser already holds your CSS, JavaScript, fonts and images only needs the HTML, and sometimes not even that. Yet many sites send cache headers that are either too timid, so browsers re-download files that never changed, or too bold, so visitors see a stale page after a deploy.

Most of the confusion goes away with one idea: separate files whose URL changes when their content changes from files whose URL stays the same. The first group can be cached for a very long time; the second must be checked with the server. This article sets up both, explains the directives involved, and covers the deploy details that make it safe.

The two kinds of URLs

Fingerprinted assets have a content hash in their name, such as /assets/app.3f9a1c2e.js or /_astro/hero.b71d.webp. Modern build tools produce these by default. When the file changes, the hash changes, and the HTML points to a new URL. The old URL’s content never changes, so a browser can keep it for as long as it likes.

Stable URLs keep the same address while their content changes: the HTML pages themselves, /robots.txt, /favicon.ico, /sitemap.xml, and any asset that a tool or CMS writes without a hash. Caching these for a long time means visitors keep the old version after you update it.

Your caching policy follows from that split:

# Fingerprinted assets: cache for a year, never revalidate
Cache-Control: public, max-age=31536000, immutable

# HTML: store, but check with the server before every use
Cache-Control: no-cache

The directives that matter

The Cache-Control header is defined in RFC 9111. A handful of its directives cover nearly every website:

  • max-age=N: the response is fresh for N seconds. While fresh, the browser uses it without contacting the server.
  • no-cache: the response may be stored, but must be revalidated with the server before each use. Despite its name, it doesn’t mean “don’t cache”.
  • no-store: the response must not be stored at all. Use it for responses with private data, such as account pages or API answers with personal information.
  • public and private: whether shared caches such as CDNs and proxies may store the response. private limits it to the visitor’s own browser.
  • s-maxage=N: a freshness lifetime that applies only to shared caches, overriding max-age there.
  • immutable: the response will not change during its freshness lifetime, so the browser shouldn’t revalidate it even when the user reloads the page. Defined in RFC 8246.
  • stale-while-revalidate=N: after the response goes stale, a cache may keep serving it for up to N more seconds while it fetches a fresh copy in the background.

One common misunderstanding: if you send no Cache-Control at all but do send Last-Modified, browsers are allowed to guess a freshness lifetime on their own (heuristic freshness). That is how sites end up with unpredictable caching. Always set the header explicitly.

Revalidation keeps HTML fresh and cheap

no-cache on HTML sounds expensive, but revalidation is usually small. When the browser has a stored copy, it asks the server whether it changed, using a validator from the earlier response:

# First response
HTTP/2 200
Cache-Control: no-cache
ETag: "6c3e-5f1a"

# Next visit: the browser sends
If-None-Match: "6c3e-5f1a"

# If nothing changed, the server answers with headers only
HTTP/2 304
ETag: "6c3e-5f1a"

A 304 Not Modified has no body, so the cost is one round trip. Most web servers generate ETag or Last-Modified for static files automatically. For dynamically rendered HTML you may need to compute an ETag yourself, or accept a full response each time, which is fine for many sites.

Some teams prefer a short max-age for HTML, such as a minute, to save the round trip on rapid navigation. That works, as long as everyone agrees that a deploy can take that long to reach every visitor.

A note on no-store for HTML: it is the right choice for pages with personal data, but avoid it on public pages without a reason. Besides forcing a full download every time, it can keep pages out of the back/forward cache in some browsers, which makes the back button slower.

An nginx configuration

A typical setup for a site whose build writes hashed files to /assets/:

server {
    # ...

    location ^~ /assets/ {
        add_header Cache-Control "public, max-age=31536000, immutable" always;
        try_files $uri =404;
    }

    location ~* \.(?:ico|txt|xml|webmanifest)$ {
        add_header Cache-Control "public, max-age=86400" always;
    }

    location / {
        add_header Cache-Control "no-cache" always;
        try_files $uri $uri/ /index.html;
    }
}

Watch for one nginx behavior that catches many people: by default, a location block that contains its own add_header does not inherit add_header directives from the server level. If your security headers are defined at the server level, they disappear from every location that adds a Cache-Control header. Repeat them in each location, or keep them in a shared include file.

The ^~ modifier stops nginx from checking the regular-expression location afterwards, so a hashed .xml or .txt file under /assets/ still gets the long lifetime. The try_files $uri =404 line in the assets block matters too. Without it, a request for a missing hashed file could fall through to your HTML fallback and get cached for a year as JavaScript.

CDNs and shared caches

If a CDN sits in front of your server, it reads the same headers. A few things to check:

  • Separate browser and CDN lifetimes when useful. Cache-Control: public, max-age=0, s-maxage=300 lets the CDN serve HTML for five minutes while browsers always revalidate. Combine it with a cache purge on deploy.
  • Don’t cache personalized responses. A response with Set-Cookie or user-specific content must be private or no-store. Many CDNs skip caching when they see Set-Cookie, but don’t rely on a default you haven’t checked.
  • Mind Vary. Vary: Accept-Encoding is normal and needed for compressed responses. Varying on User-Agent or Cookie fragments the cache into near-uselessness.

Our guides on browser caching and CDNs and hosting cover how these settings are evaluated.

Deploys without broken pages

Long-lived asset caching only works if deploys respect a few rules:

  1. Upload new assets before switching the HTML. If the new HTML goes live first, visitors request hashed files that don’t exist yet.
  2. Keep old assets for a while. A visitor who loaded the old HTML a minute ago will still ask for the old hashed files, for example when a lazily loaded chunk is requested later. Deleting them on every deploy causes errors for people who are mid-visit. Keep the previous versions for at least a few days.
  3. Never reuse a hashed URL for different content. If a build tool produces stable names for some files, such as a service worker or a manifest, give those files a short lifetime or no-cache.
  4. Purge HTML at the CDN on deploy if it caches HTML at all.

Compression and caching go together

Caching saves the repeat download; compression shrinks the first one. Text assets such as HTML, CSS, JavaScript, SVG and JSON should be served with Brotli or gzip. Since hashed files never change, you can compress them at build time at the highest level instead of on every request. Images in JPEG, PNG, WebP or AVIF are already compressed and gain nothing from another pass. The text compression guide has the details.

Checking your headers

Look at real responses rather than your configuration files, since proxies and CDNs can change headers on the way:

curl -sI https://example.com/ | grep -i -E 'cache-control|etag|last-modified|age'
curl -sI https://example.com/assets/app.3f9a1c2e.js | grep -i cache-control

In Chrome DevTools, the Network panel’s Size column shows “(memory cache)” or “(disk cache)” for responses served locally, and a 304 status for successful revalidations. Reload the page normally and check that hashed assets don’t trigger any request at all.

A Lighthouse run also lists static resources with short cache lifetimes. Expect it to flag third-party files whose headers you don’t control; those are a reason to self-host where licenses allow, or a cost to accept.

The setup above is deliberately simple: a year and immutable for anything with a hash, no-cache for HTML, a day for the few stable files in between. Most caching problems come from deviating from that pattern without a specific reason.

Tags:HTTP cachingCache-ControlCDNnginxstatic assets