Choosing max-age for Hashed vs Unhashed Assets
max-age is the only cache directive most sites ever need to think hard about, and the correct value for it is decided by a single property of the URL: whether the filename encodes the bytes it serves. Get that classification right and half your cache configuration writes itself; get it wrong and you either burn a year of browser cache on a file you will need to change next Tuesday, or you pay for revalidation traffic on files that could not possibly have changed.
The Only Question That Matters
Before arguing about seconds, answer one thing: if the content of this file changes, does its URL change too?
A URL like /assets/main.a3f8c1d2.js is content-addressed. The eight hex characters are derived from the bytes, so different bytes produce a different path and the old path either keeps serving the old bytes or 404s — it never lies. A URL like /favicon.ico or /logo.svg is a name, not a fingerprint. The same path serves whatever the last deploy put there.
Everything downstream follows from that answer. Content-addressed URLs have no freshness problem to solve, so you maximise the TTL and stop. Named URLs have a genuine trade-off with no universally correct answer, so you pick a TTL per class of file based on how badly a stale copy hurts and how much traffic a short TTL costs.
Hashed URLs: One Answer, No Tuning
For any path produced by a bundler with a content hash in the name, the header is:
Cache-Control: public, max-age=31536000, immutable
That is the complete configuration. 31536000 is one year in seconds, which is the ceiling the HTTP specification advises caches to honour; larger values are legal but clamp in practice. immutable tells the browser not to issue a conditional request even on a reload, which is the difference between zero network activity and a round trip that returns 304 Not Modified for every asset on the page.
Deliberately absent from that line:
- No
ETagdependency. A validator is useful when a cache needs to ask “has this changed?” A content-addressed URL cannot change, so the question is never asked. The relationship between the two mechanisms is worked through in ETag vs immutable Cache-Control for assets. - No
s-maxage. Splitting edge TTL from browser TTL is pointless when both should be a year. - No
must-revalidate. It only takes effect once the response is stale, and this response does not become stale inside any realistic session. - No
stale-while-revalidate. Same reason: there is nothing to revalidate.
Eight hex characters of hash is the working default and gives ample collision headroom for a typical application; monorepos emitting thousands of chunks usually move to 12–16. The same policy applies to hashed non-code assets — see hashing images, fonts and media for the naming patterns that make a .woff2 or an .avif eligible for this lane.
Unhashed URLs: Trading Freshness Against Request Volume
Every project has files the bundler never touches. They sit at fixed paths because something outside your build refers to them by name: a browser convention, a crawler, a social platform’s scraper, a database row.
Typical members of this class:
/favicon.icoand the PNG icon set — referenced by browsers and bookmark UIs at fixed paths./robots.txt— fetched by crawlers on their own schedule./manifest.webmanifest— fetched by the browser during install and update checks./logo.svgand other brand marks referenced from CMS content or e-mail templates.- Open Graph images — fetched once by each social platform’s scraper and cached on their side for days regardless of your header.
- Uploaded user media at
/uploads/<id>.jpg— mutable if your product lets a user replace a file in place.
For each of these the TTL is a straight trade. A long TTL means fewer requests and faster loads, but a wrong or outdated file survives in caches for the whole window with no way to reach it. A short TTL means you can fix things quickly, but every visitor’s browser and every edge location pays for conditional requests you will almost always answer with 304.
The Decision Table
| Asset class | Example path | Recommended directive | Why |
|---|---|---|---|
| Hashed JS/CSS chunk | /assets/main.a3f8c1d2.js |
public, max-age=31536000, immutable |
URL changes with content; nothing can go stale |
| Hashed font or image | /assets/inter.9b4e7f12.woff2 |
public, max-age=31536000, immutable |
Same guarantee; also avoids CORS re-fetches |
| HTML entry point | /, /index.html |
public, s-maxage=60, no-cache, stale-while-revalidate=600 |
Mutable at a fixed URL; must not outlive the asset set it names |
| Favicon set | /favicon.ico |
public, max-age=604800 |
Rarely changes; a week of staleness is cosmetic |
| Web app manifest | /manifest.webmanifest |
public, max-age=86400 |
Install metadata; a day keeps update checks cheap |
| Crawler policy | /robots.txt |
public, max-age=3600, s-maxage=86400 |
Crawlers re-read on their own cadence; you may need a fast fix |
| Brand mark | /logo.svg |
public, s-maxage=604800, max-age=86400 |
Edge absorbs volume; browsers stay correctable within a day |
| Open Graph image | /og/default.png |
public, max-age=86400, stale-if-error=604800 |
Scrapers cache independently; resilience matters more than freshness |
| Replaceable user upload | /uploads/4821.jpg |
public, max-age=300, s-maxage=3600 |
Bytes can change under a stable id; keep the window short |
| Immutable user upload | /uploads/4821-a3f8c1d2.jpg |
public, max-age=31536000, immutable |
Adding a hash promotes it into the safe lane |
The last two rows are the interesting pair. The difference between a five-minute TTL and a one-year TTL on user media is not a caching decision at all — it is a naming decision made in your upload pipeline. Appending a content hash to the stored object key moves the file permanently into the left branch of the decision tree.
Splitting Edge TTL from Browser TTL with s-maxage
s-maxage applies only to shared caches: CDN nodes, reverse proxies, corporate caches. Private browser caches ignore it and fall back to max-age. That asymmetry is the main lever for unhashed files, because it lets you buy request-volume relief at the edge without extending the window during which you cannot correct a mistake in someone’s browser.
Cache-Control: public, s-maxage=604800, max-age=3600, stale-if-error=86400
The edge holds the object for a week and absorbs essentially all the traffic. Browsers hold it for an hour, so a bad /logo.svg is fully corrected within an hour of a purge rather than within a week. A purge reaches the edge instantly; it cannot reach a browser at all.
must-revalidate and no-cache: What They Actually Mean
no-cache is the most misread token in the header. It does not mean “do not cache”. It means “you may store this, but you must check with the origin before you reuse it”. A response marked no-cache still lives on disk, and a successful revalidation returns a bodyless 304, so the bandwidth saving of caching is preserved even though the round trip is not. The directive that actually forbids storage is no-store, and it should be reserved for responses containing credentials or personal data — using it on a public file just deletes a cache tier for no benefit.
must-revalidate changes behaviour only after freshness has already lapsed. Normally a cache is permitted, in constrained situations such as a network outage, to serve a stale response with a Warning. must-revalidate withdraws that permission: once the TTL is up, the cache must reach the origin or return a 504. That makes it appropriate for a pricing table or an inventory count, and actively harmful on a logo — you have traded availability for a freshness guarantee you did not need.
The pairing max-age=0, must-revalidate is functionally close to no-cache and appears widely in older configurations. Prefer no-cache for new work; it expresses the intent in one token and behaves identically in every current cache implementation.
The Middle Ground: stale-while-revalidate and stale-if-error
Between “always ask” and “never ask” sit the two staleness extensions, and for unhashed assets they usually give you more than another round of max-age tuning would.
stale-while-revalidate=N lets a cache hand back the expired copy immediately and refresh it in the background, so the TTL boundary stops being a latency cliff. stale-if-error=N lets a cache keep serving the expired copy when the origin is failing, which converts an origin outage into a non-event for anything that was recently requested.
Cache-Control: public, s-maxage=86400, max-age=3600, stale-while-revalidate=600, stale-if-error=604800
For a logo or an Open Graph image that header is close to ideal: an hour of browser TTL you can correct, a day of edge TTL that absorbs the traffic, ten minutes of asynchronous refresh so nobody waits at the boundary, and a week of outage tolerance. The same technique applied to documents is covered in stale-while-revalidate for HTML entry points, where the stale window interacts with which asset versions are still on disk.
Implementing the Matrix in Nginx
server {
listen 443 ssl http2;
server_name example.com;
root /var/www/html;
# Content-addressed output from the bundler
location ~* ^/assets/[^/]+\.[0-9a-f]{8,16}\.(js|css|woff2|png|webp|avif|svg)$ {
add_header Cache-Control "public, max-age=31536000, immutable" always;
add_header Vary "Accept-Encoding" always;
access_log off;
}
# Content-addressed user uploads
location ~* ^/uploads/[0-9]+-[0-9a-f]{8,16}\.(jpg|png|webp)$ {
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
# Replaceable user uploads at a stable id
location ~* ^/uploads/[0-9]+\.(jpg|png|webp)$ {
add_header Cache-Control "public, max-age=300, s-maxage=3600" always;
}
# Brand marks and Open Graph images
location ~* ^/(logo\.svg|og/.+\.png)$ {
add_header Cache-Control "public, s-maxage=86400, max-age=3600, stale-while-revalidate=600, stale-if-error=604800" always;
}
location = /favicon.ico {
add_header Cache-Control "public, max-age=604800" always;
}
location = /manifest.webmanifest {
add_header Cache-Control "public, max-age=86400" always;
types { } default_type application/manifest+json;
}
location = /robots.txt {
add_header Cache-Control "public, max-age=3600, s-maxage=86400" always;
}
location / {
add_header Cache-Control "public, s-maxage=60, no-cache, stale-while-revalidate=600" always;
try_files $uri $uri/ /index.html;
}
}
The always flag matters more here than anywhere else on the page: without it Nginx drops these headers on 4xx and 5xx responses, and a 404 for a missing upload can then inherit a CDN default TTL and pin the error in place.
Implementing the Matrix as a Cloudflare Cache Rule
Cloudflare evaluates rules top to bottom, so order the expressions from most specific to least. Create one Cache Rule per class with an Edge TTL override, and a paired Response Header Transform Rule that writes the browser-facing string.
# Rule 1 — content-addressed output
(http.request.uri.path matches "^/(assets|uploads)/.*[.-][0-9a-f]{8,16}\\.[a-z0-9]+$")
Cache eligibility : eligible for cache
Edge TTL : override to 31536000
Browser TTL : override to 31536000
# Rule 2 — brand marks and social images
(http.request.uri.path eq "/logo.svg" or starts_with(http.request.uri.path, "/og/"))
Cache eligibility : eligible for cache
Edge TTL : override to 86400
Browser TTL : override to 3600
# Rule 3 — replaceable uploads
(http.request.uri.path matches "^/uploads/[0-9]+\\.(jpg|png|webp)$")
Cache eligibility : eligible for cache
Edge TTL : override to 3600
Browser TTL : override to 300
# Rule 4 — documents
(http.request.uri.path eq "/" or ends_with(http.request.uri.path, ".html"))
Cache eligibility : eligible for cache
Edge TTL : override to 60
Browser TTL : bypass cache
The matching Transform Rule for rule 1 sets a static header value:
Set static header : Cache-Control
Value : public, max-age=31536000, immutable
Repeat that pairing for each class using the directive from the decision table. Keeping the Edge TTL override and the header string in agreement is what makes the curl sweep below meaningful — when they disagree, the edge follows its own override and the header you read is a fiction. The wider rule-ordering model is covered in the Cloudflare cache rules and purge reference.
Verification: A curl Sweep Across the Classes
Assert on the literal header string for one representative URL per class. Run it after every deploy that touches routing.
#!/usr/bin/env bash
# verify-cache-headers.sh — one representative URL per asset class
set -euo pipefail
HOST="https://example.com"
declare -A EXPECT=(
["/assets/main.a3f8c1d2.js"]="public, max-age=31536000, immutable"
["/uploads/4821-a3f8c1d2.jpg"]="public, max-age=31536000, immutable"
["/uploads/4821.jpg"]="public, max-age=300, s-maxage=3600"
["/favicon.ico"]="public, max-age=604800"
["/manifest.webmanifest"]="public, max-age=86400"
["/robots.txt"]="public, max-age=3600, s-maxage=86400"
)
fail=0
for path in "${!EXPECT[@]}"; do
actual=$(curl -sSI "${HOST}${path}" \
| tr -d '\r' \
| awk -F': ' 'tolower($1)=="cache-control" {print $2}')
if [ "$actual" = "${EXPECT[$path]}" ]; then
printf 'ok %-32s %s\n' "$path" "$actual"
else
printf 'FAIL %-32s got:%s want:%s\n' "$path" "${actual:-<none>}" "${EXPECT[$path]}"
fail=1
fi
done
exit "$fail"
Two follow-ups are worth running by hand. Repeat a request to a hashed asset and confirm the Age header climbs — a permanently zero Age means the edge is not storing the object, usually because a Set-Cookie slipped onto the response. Then send a fabricated validator to a hashed asset and confirm you get a 200, not a 304:
curl -sSI -H 'If-None-Match: "not-a-real-etag"' \
https://example.com/assets/main.a3f8c1d2.js | head -1
The Year You Cannot Take Back
The asymmetry that justifies this whole exercise: a purge clears every shared cache in seconds, and reaches exactly zero browsers. If you ship max-age=31536000 on /logo.svg and the logo is wrong, the only remedies are to change the URL — which means editing every reference to it — or to wait out the year on the devices that already fetched it.
Two habits keep this from happening. First, never let an unhashed path fall through into a rule written for hashed paths; anchor the hash pattern in the regex rather than matching a bare directory prefix. Second, treat any browser TTL longer than a day on an unhashed file as a decision that needs a reason, and write that reason next to the rule. Reaching for s-maxage instead is nearly always the better move: it buys the same origin offload and leaves the recovery path intact.
FAQ
Is max-age=31536000 better than a larger number?
No, and larger numbers can be worse. One year is the value the specification advises caches to treat as an effective ceiling, and it is what every CDN control panel and header-linting tool expects to see. Values beyond it are clamped by most implementations, so the only thing a larger number achieves is making your configuration look unfamiliar in review.
Should robots.txt be cached at all?
Yes, but keep the browser TTL short. Crawlers re-fetch it on their own schedule and generally hold their own copy for up to a day regardless of what you send, so a long browser TTL buys nothing while removing your ability to correct a rule that is blocking indexing. An hour of max-age with a day of s-maxage gives the edge the traffic relief and keeps the fix path fast.
What TTL should uploaded user media get?
It depends entirely on whether your product allows replacing a file at the same identifier. If it does, keep the browser TTL in the low hundreds of seconds and lean on s-maxage for the offload. If it does not — or if you can change the pipeline so it does not, by storing objects under a key that includes a content hash — treat them exactly like build output and take the full year.
Does immutable do anything if I already send a one-year max-age?
Yes. Without it, browsers still issue a conditional request for a fresh resource when the user reloads the page, which is a full round trip per asset for a 304. immutable suppresses that revalidation, so a reload on a page with forty hashed assets goes from forty conditional requests to none.
Related
- Cache-Control, immutable and TTL tuning — the full directive reference and per-provider configuration this decision feeds into
- stale-while-revalidate for HTML entry points — asynchronous refresh for the one file that can never take a long TTL
- ETag vs immutable Cache-Control for assets — why a content-addressed URL makes the validator redundant
- Hashing images, fonts and media — moving non-code assets out of the unhashed column and into the immutable one