Cache Headers That Work: Hashed Assets, immutable and Revalidation

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.publicandprivate: whether shared caches such as CDNs and proxies may store the response.privatelimits it to the visitor’s own browser.s-maxage=N: a freshness lifetime that applies only to shared caches, overridingmax-agethere.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=300lets 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-Cookieor user-specific content must beprivateorno-store. Many CDNs skip caching when they seeSet-Cookie, but don’t rely on a default you haven’t checked. - Mind
Vary.Vary: Accept-Encodingis normal and needed for compressed responses. Varying onUser-AgentorCookiefragments 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:
- Upload new assets before switching the HTML. If the new HTML goes live first, visitors request hashed files that don’t exist yet.
- 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.
- 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. - 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.