Setting Explicit Freshness Defaults Across a Site

Problem Statement

Somewhere in a site of any size there are responses shipping with no Cache-Control and no Expires. Each one hands its lifetime to whichever cache sees it first. The goal is not to audit them one by one but to make the situation impossible: a layered configuration where a response without an explicit lifetime cannot leave the building.

Prerequisite Concepts

Step-by-Step Resolution

Step 1 — Find the routes that ship without a lifetime

A crawl of the sitemap, checking two headers, is enough to size the problem:

while read -r url; do
  hdrs=$(curl -sI "$url")
  if ! grep -qiE 'cache-control:.*(max-age|s-maxage)|^expires:' <<<"$hdrs"; then
    printf 'NO LIFETIME  %s\n' "$url"
  fi
done < urls.txt

Run it against a list that includes error paths and asset URLs, not only pages — those are the routes most likely to be missing a header and most damaging when they are.

The three layers that guarantee a lifetimeA conservative default catches everything, route rules give the correct value where it matters, and an edge guard covers anything that loses its header in transit.MOST SPECIFIC1Route overridesassets, APIs, errors2Server-wide defaultshort and harmless3Edge guardstamps a value on anything missingLAST LINE OF DEFENCE
Three layers, each covering the gaps in the one above

Step 2 — Add the server-wide default

The default should be short enough that landing on an unconsidered route is harmless:

# Every response gets a lifetime; specific locations override it
add_header Cache-Control "public, max-age=60" always;

The always flag applies the header to error responses too, which is where directive-free responses most often originate.

Step 3 — Override per route class

Three classes cover almost everything:

# Fingerprinted assets — the URL changes when the bytes do
location ~* "-[0-9a-f]{8}\.(?:js|css|woff2)$" {
    add_header Cache-Control "public, max-age=31536000, immutable" always;
}

# Dynamic documents — browsers revalidate, the edge absorbs the load
location / {
    add_header Cache-Control "public, max-age=0, s-maxage=600" always;
    etag on;
}

# Authenticated API — private, cheap to revalidate
location /api/me {
    add_header Cache-Control "private, no-cache" always;
}
A default table worth copyingFour route classes, the directive each needs, and the consequence of leaving it to the heuristic instead.Explicit defaultIf left to the heuristicFingerprinted assetpublic, max-age=31536000, immutablerefetched every few hoursHTML documentpublic, max-age=0, s-maxage=600stale for an invented durationAuthenticated APIprivate, no-cachepossibly stored at the edgeError responsepublic, max-age=5outlives the fault that caused it
Four route classes and their explicit defaults

Step 4 — Add the edge guard

sub vcl_backend_response {
    if (!beresp.http.Cache-Control && !beresp.http.Expires) {
        set beresp.http.Cache-Control = "public, max-age=0, s-maxage=60";
    }
}

This is deliberately conservative: a zero browser lifetime and a short shared lifetime is safe on almost any content, and its only job is to stop a heuristic being applied.

Step 5 — Assert it in the build

A test that samples one URL per route class and fails when a lifetime is missing turns a recurring audit into a build failure:

set -e
for url in https://staging.example.com/ \
           https://staging.example.com/assets/app.b7f31e2a.js \
           https://staging.example.com/api/me \
           https://staging.example.com/definitely-missing; do
  curl -sI "$url" | grep -qiE 'cache-control:.*(max-age|s-maxage|no-store)' \
    || { echo "no explicit lifetime: $url"; exit 1; }
done

Expected Output / Verification

Every sampled route returns an explicit directive, and the values differ by class rather than being uniform:

GET /assets/app.b7f31e2a.js  -> Cache-Control: public, max-age=31536000, immutable
GET /                        -> Cache-Control: public, max-age=0, s-maxage=600
GET /api/me                  -> Cache-Control: private, no-cache
GET /definitely-missing      -> Cache-Control: public, max-age=5

Uniform values across all four are a sign the overrides are not matching and everything is falling through to the default.

Routes shipping without a lifetime, before and afterMost directive-free responses come from paths nobody configured — error handlers, redirects and static handlers — which is exactly what a server-wide default covers.Before214routes mostly errors and redirectsAfter default12routes CDN-generated responsesAfter edge guard0routessampled across 4200 crawled URLs
Directive-free responses found by a full-site crawl

Edge Cases

  • A more specific rule that never matches. An asset regex that does not match your actual filename pattern silently falls through to the site default, giving fingerprinted files a 60-second lifetime. Verify each override individually rather than trusting the configuration to be correct.
  • add_header without always. Nginx omits it on non-2xx responses, so error paths keep shipping bare. This single flag accounts for most incomplete rollouts.
  • CDN-generated responses. An error page produced by the edge when the origin is unreachable never passes through the origin configuration. The edge guard is the only layer that covers it.
  • Redirect responses inheriting the page default. A 301 or 302 emitted by a rewrite rule usually bypasses the location block that would have given it a sensible lifetime, so it picks up the site-wide value instead. On a permanent redirect that is often acceptable; on a temporary one it is not, because browsers will keep following a redirect you have already withdrawn. Give redirect handlers their own explicit rule rather than letting them inherit.
  • A default applied to no-store routes. A blanket public default landing on a route that should never be stored is a worse outcome than the heuristic. Order the rules so sensitive routes are matched first, and verify them explicitly.

Frequently Asked Questions

Why not simply set no-store everywhere and be safe?

Because it removes every performance benefit caching provides and makes origin load scale linearly with users. The goal is a deliberate lifetime on every response, not the absence of caching. A short max-age with a validator gives correctness at a fraction of the cost.

What is a safe server-wide default?

Something short enough to be harmless if it lands on a route you did not consider — public, max-age=60 is a common choice. The default exists to prevent the heuristic, not to be optimal; routes that need a different value should override it explicitly.

Does a default header break fingerprinted assets?

Only if the override is missing. Fingerprinted assets need a long max-age with immutable, applied by a more specific rule that takes precedence over the site default. Verify the override rather than assuming it applies.

Do I still need an edge guard if the origin is configured correctly?

It is worth having. Headers are lost in rewrites, proxies and error paths more often than expected, and the guard costs one rule. It also protects responses the CDN generates itself, which never pass through the origin’s configuration at all.


Back to Heuristic Freshness and Implicit Caching