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.

Choosing max-age from the URL A decision tree: if the URL contains a content hash, serve max-age 31536000 with immutable and nothing else. If it does not, ask whether hours of staleness are acceptable; if yes use a long shared TTL with stale-if-error, if no use a short shared TTL with no-cache and stale-while-revalidate. One question decides the header Content hash in the URL? yes no max-age=31536000 immutable and nothing else never needs a purge Is stale for hours acceptable? yes no s-maxage=604800 max-age=86400 stale-if-error=86400 s-maxage=60 no-cache stale-while-revalidate
The left branch has one answer for every project; the right branch is where the actual judgement lives.

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 ETag dependency. 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.ico and 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.svg and 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.

Revalidation traffic against worst-case staleness Six rows from no-cache to max-age 31536000. Bar length shows relative revalidation traffic, which falls as the TTL grows, while the worst-case staleness column grows from instant to one year. Freshness and request volume trade directly Directive Revalidation traffic Worst-case staleness no-cache instant max-age=300 5 minutes max-age=3600 1 hour max-age=86400 1 day max-age=604800 1 week 31536000, immutable 1 year (hashed only) Only a content-addressed URL lets you take the bottom row.
Every row above the last is a compromise; the last row is only available because the URL itself changed.

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.

Which layer reads which directive The browser cache obeys max-age and immutable and never sees s-maxage. The CDN edge prefers s-maxage over max-age and honours stale-while-revalidate and stale-if-error. The origin emits the single header string that both layers interpret. Which layer reads which directive Browser cache obeys max-age and immutable; never sees s-maxage no-cache here means: ask before reusing CDN edge s-maxage wins over max-age; honours SWR and stale-if-error one revalidation per location, not per user Origin emits the header; must-revalidate and no-store start here everything above is downstream of this one string One header at the origin; three layers reading different parts of it.
A purge can reset the middle layer at will. It has no reach into the top layer 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.