Skip to content
Foxipa

Suggest an edit

Tell us what should be corrected, clarified, or improved. Submission will be connected to Foxipa’s contribution service later.

No information is sent anywhere yet. Your suggestion is only prepared in your browser.

Learning

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.

Levels: Intermediate ~35 min read

Intermediate

What you will learn

  1. The TTL precedence tree, proven live: origin headers vs X-Accel-Expires vs proxy_cache_valid.

  2. All seven $upstream_cache_status values — the debugging alphabet.

  3. proxy_cache_bypass and proxy_no_cache — skipping the lookup and the store independently.

  4. The audit trail: log formats that tell an entry's whole story.

  5. proxy_cache_revalidate and 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:

text
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 ALL

Both 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:

text
$ 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, refetched

Three honest notes on the tree:

  • proxy_cache_valid is a fallback, not an override. Teams set it and wonder why their origin's max-age=3600 still 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) then proxy_cache_valid governs. Deliberate, documented, rare.

  • X-Accel-Expires is 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-Status plus one curl -D - at the origin (does it send Cache-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

MISS

no entry; fetched from origin

cold cache, new key

HIT

served from cache

the good case — rt=- in the log (no upstream time)

EXPIRED

entry existed, TTL lapsed; refetched

revalidation (cheap if 304)

STALE

expired entry served anyway

Lesson 8.4's use_stale doing its job

UPDATING

stale while a background refresh runs

background_update pairs with STALE

BYPASS

lookup skipped entirely

proxy_cache_bypass fired (§3)

REVALIDATED

origin said 304; stored body re-served

validators working

— (empty)

the request was served without consulting any cache — the variable is an empty string, which access logs print as -

a location without proxy_cache — the control group

The log line tells the story with two fields — from a real capture:

text
"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:

nginx
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:

text
$ 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 it

Read 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:

nginx
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, send If-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

  1. Walk the precedence tree. curl -D - http://127.0.0.1:9001/cache/5 (origin speaks), then watch /cached/cache/5?x cycle MISS→HIT→EXPIRED with sleep 6 — then compare against /valid/info (silent origin + directive). You've exercised both branches.

  2. 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.

  3. Build the secret menu. Add $http_x_refresh versions of bypass/no_cache to /cached/, reload, and peek at the origin with curl -H 'X-Refresh: 1' — then confirm the cached entry survived.

  4. 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 -). The rt=- log signature is a HIT’s fingerprint.

  • bypass skips serving-from-cache; no_cache skips 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.

Was this page helpful?

Mark this learning resource complete to track your learning progress.