8.3 TTLs, Cache Status, and Bypassing
How long entries live, what every verdict means, and how to carve holes in the cache when correctness demands it. This is the lesson that makes caches debuggable.
Intermediate
What you will learn
The TTL precedence tree, proven live: origin headers vs
X-Accel-Expiresvsproxy_cache_valid.All seven
$upstream_cache_statusvalues — the debugging alphabet.proxy_cache_bypassandproxy_no_cache— skipping the lookup and the store independently.The audit trail: log formats that tell an entry's whole story.
proxy_cache_revalidateand friends — the knobs around expiry.
1. The TTL decision tree, proven
Who decides how long an entry is fresh? All of them, in this order — first match wins:
origin sent X-Accel-Expires: N → N seconds (NGINX's private channel)
origin sent Cache-Control: s-maxage=N → N (shared-cache TTL)
origin sent Cache-Control: max-age=N → N
origin sent Expires: <date> → date − Date
none of the above:
proxy_cache_valid <codes> <time> → that time
no directive → NOT CACHED AT ALLBoth ends of the tree, proven. The
origin-header path: an echo backend's
/cache/SECONDS route sends real
Cache-Control: public, max-age=N — entries live exactly that long (the stale-serving experiments of Lesson 8.4 are built on
/cache/4's four-second TTL). The
directive path:
/valid/ proxies to
/info (an origin that sends
no cache headers) under
proxy_cache_valid 200 301 3s — and the full lifecycle, captured:
$ curl -s -o /dev/null -D - http://127.0.0.1:8800/valid/ | grep -i x-cache
X-Cache-Status: MISS # stored — the 3s TTL clock starts
$ curl -s -o /dev/null -D - http://127.0.0.1:8800/valid/ | grep -i x-cache
X-Cache-Status: HIT # within proxy_cache_valid 3s — served fresh
$ sleep 4.5; curl -s -o /dev/null -D - http://127.0.0.1:8800/valid/ | grep -i x-cache
X-Cache-Status: EXPIRED # 4.5s later: TTL lapsed, refetchedThree honest notes on the tree:
proxy_cache_validis a fallback, not an override. Teams set it and wonder why their origin'smax-age=3600still wins — it's supposed to. To impose an edge TTL over a chatty origin:proxy_ignore_headers Cache-Control Expires Set-Cookie;(strip the origin's opinions) thenproxy_cache_validgoverns. Deliberate, documented, rare.X-Accel-Expiresis the origin's side-door to you — an application that knows better than its own public headers can say "cache me 30 s at the edge regardless". Apps set it; NGINX obeys; browsers never see it. (Sibling:X-Accel-Redirect, Module 11.)A silent origin caches nothing — the #1 "MISS forever" cause.
X-Cache-Statusplus onecurl -D -at the origin (does it sendCache-Control?) resolves it in seconds.
2. The debugging alphabet: all seven statuses
Every proxied response can tell you its cache story via
$upstream_cache_status (expose it as
X-Cache-Status on every response and
cache= in every log line):
Status | Meaning | You feel it when |
|---|---|---|
| no entry; fetched from origin | cold cache, new key |
| served from cache | the good case —
|
| entry existed, TTL lapsed; refetched | revalidation (cheap if 304) |
| expired entry served anyway | Lesson 8.4's
|
| stale while a background refresh runs |
|
| lookup skipped entirely |
|
| origin said 304; stored body re-served | validators working |
| the request was served without consulting any cache — the variable is an empty string, which access logs print as
| a location without
|
The log line tells the story with two fields — from a real capture:
"GET /cached/cache/60?demo=1 HTTP/1.1" 200 cache=MISS bytes=95 rt=0.001
"GET /cached/cache/60?demo=1 HTTP/1.1" 200 cache=HIT bytes=95 rt=-
rt=0.001 vs
rt=- (no upstream contact at all) — a HIT isn't just a status, it's a
vanished round trip. Dashboards (Module 12) chart the ratio; you now read the individual sentences.
3. Carving holes:
bypass and
no_cache
Two independent valves, both driven by your choice of condition:
location /bypass/ {
proxy_cache cache;
proxy_cache_bypass $arg_nocache; # skip the LOOKUP (always go to origin)
proxy_no_cache $arg_nocache; # skip the STORE (don't overwrite entry)
…
}The full matrix, walked:
$ curl -s -o /dev/null -D - http://127.0.0.1:8800/bypass/ | grep -i x-cache
X-Cache-Status: MISS # primes normally — stored
$ curl -s -o /dev/null -D - 'http://127.0.0.1:8800/bypass/?nocache=1' | grep -i x-cache
X-Cache-Status: BYPASS # lookup skipped — origin hit
$ curl -s -o /dev/null -D - http://127.0.0.1:8800/bypass/ | grep -i x-cache
X-Cache-Status: HIT # the cache still holds the old entry
$ curl -s -o /dev/null -D - 'http://127.0.0.1:8800/bypass/?nocache=1' | grep -i x-cache
X-Cache-Status: BYPASS # again — no_cache=1 did not overwrite itRead the semantics carefully — they differ by one word and it matters:
bypass answers "where do I
serve from?" (nowhere but origin),
no_cache answers "where do I
write?" (nowhere). Together they implement the classic operations: "refresh this URL" (
bypass=1 + store, overwriting) vs "peek at the origin" (
bypass=1 no_cache=1, leaving the entry untouched). The condition variables are the usual cast — a header (
$http_x_refresh), an argument, a cookie, a map.
4. The audit trail
The cache log format is deliberately chatty:
log_format cache '… $status cache=$upstream_cache_status '
'bytes=$body_bytes_sent rt=$upstream_response_time';Add to it as your operation matures:
$upstream_addr (which origin populated the entry),
$request_time (client-perceived latency — HITs show tiny upstream time but full request time if the
client is slow), and the Lesson 12 treatment turns these fields into hit-ratio timeseries, status-mix alarms ("BYPASS tripled — someone's condition fired"), and per-URL storytelling.
5. The expiry-adjacent knobs
Quick reference for the directives surrounding this lesson:
proxy_cache_revalidate on;— on EXPIRED, sendIf-Modified-Since/If-None-Match(the 304 dance) instead of refetching. Turn it on when origins emit validators.proxy_cache_min_uses 2;— only store on the Nth request: a one-hit-wonder URL never enters the cache (protects the index from scanner-shaped keyspaces).proxy_cache_methods GET HEAD;— the default; the honest ceiling(don't cache POSTs unless you've solved invalidation).proxy_cache_convert_head on;— HEADs served from GET entries (default, correct); off if origins differ.proxy_cache_lock— Lesson 8.4's headline, previewed here as "the reason your cold-burst MISS count is 1 instead of 50".
Try it yourself
Walk the precedence tree.
curl -D - http://127.0.0.1:9001/cache/5(origin speaks), then watch/cached/cache/5?xcycle MISS→HIT→EXPIRED withsleep 6— then compare against/valid/info(silent origin + directive). You've exercised both branches.Read the alphabet. Generate at least six distinct statuses in your log (MISS, HIT, EXPIRED are one location away; BYPASS needs §3's argument; STALE/UPDATING are Lesson 8.4's next moves) and
grep cache= /var/log/nginx/access.log | sort | uniq -c.Build the secret menu. Add
$http_x_refreshversions of bypass/no_cache to/cached/, reload, and peek at the origin withcurl -H 'X-Refresh: 1'— then confirm the cached entry survived.Impose an edge TTL. On a scratch location: add
proxy_ignore_headers Cache-Control; proxy_cache_valid 200 2s;over/cache/60— the origin's minute-long opinion reduced to two honest seconds at your edge. Watch the EXPIRED cadence change.
Q&A
Q1. Origin sends
max-age=10, you set
proxy_cache_valid 200 5m, and entries live exactly 10 s. Why — and what would make them live 5 minutes?
Answer
Origin headers outrank the directive;
proxy_cache_valid only applies to silent origins. For 5 minutes:
proxy_ignore_headers Cache-Control (+ a documented reason), or fix the origin, or negotiate via
X-Accel-Expires.
Q2.
X-Cache-Status: EXPIRED but
rt=0.000 and the log shows a 304 from the origin. Diagnose the full story.
Answer
Textbook revalidation: freshness lapsed, and — because
proxy_cache_revalidate on was in effect — NGINX sent the validators (
If-Modified-Since /
If-None-Match), the origin answered
304 Not Modified, and the stored body was re-served with a reset TTL. Without
proxy_cache_revalidate the expiry refetch is unconditional — a full
200, never a 304. Cheap correctness — exactly what the validation contract buys.
Q3. One customer reports stale data; everyone else is fresh. You suspect a per-user entry. Which two directives let you inspect the origin's current answer without disturbing their cached entry?
Answer
proxy_cache_bypass $… +
proxy_no_cache $… driven by the same condition (the X-Refresh idiom): lookup skipped (origin truth visible), store skipped (their entry untouched).
Q4. Why does
proxy_cache_min_uses 2 improve cache
health on public edges?
Answer
Scanner and one-shot traffic generates enormous single-use keyspaces; requiring a second request before storing keeps the index and disk devoted to URLs with actual audiences. (Cost: genuinely-rare content never caches — a trade, not a free lunch.)
Takeaways
TTL precedence:
X-Accel-Expires>Cache-Control(s-maxage /max-age) >Expires>proxy_cache_valid> nothing (no caching). Imposing edge TTLs means ignoring origin headers — deliberately.Seven statuses, each a sentence:
MISS/HIT/EXPIRED/STALE/UPDATING/BYPASS/REVALIDATED— plus an empty value when no cache is involved (access logs print it as-). Thert=-log signature is a HIT’s fingerprint.bypassskips serving-from-cache;no_cacheskips writing-to-cache — independent valves, jointly the "peek vs refresh" toolkit, best driven by an ops header.Revalidation (
proxy_cache_revalidate+ validators) turns expiry from refetch into a 304 handshake.Every cache decision belongs in the log line — you can't operate what you can't read.
Thanks for your feedback.
Mark this learning resource complete to track your learning progress.