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
- Heuristic freshness and implicit caching — what happens to a directive-free response, and why the outcome varies by cache.
- Header stacking and directive precedence — how a specific rule overrides a general one when both apply.
- Cache hit, miss and bypass mechanics — the outcomes each default will produce once it is in place.
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.
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;
}
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.
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_headerwithoutalways. 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
301or302emitted 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-storeroutes. A blanketpublicdefault 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.
Related
- Caching hashed static assets with immutable covers the one route class where a year-long default is the correct answer.
- Cache-Control best practices for REST APIs gives the per-endpoint values the override layer should apply.
- Public vs private cache scope explains the scope half of the default, which matters as much as the lifetime.