8.1 The Caching Mindset: Contracts Before Directives
Directives are easy; the thinking is the hard part. This lesson installs the three contracts every cache honors — identity, freshness, and validation — before the module’s directives arrive.
Intermediate
What you will learn
Why the edge wins: the economics of a cache hit.
The three contracts: identity (what a cache entry is), freshness (how long it may be served), validation (how to cheaply re-check it).
The four caching actors: origin, proxy cache, browser, CDN — who honors which contract.
What must never be cached, and how the contract says so.
The vocabulary you'll use for the rest of the module.
1. The economics
Every cache hit converts this:
client ──▶ [edge NGINX] ──▶ [app tiers] ──▶ [database] ──▶ disk
(100ms+, real money, real failure modes)into this:
client ──▶ [edge NGINX: hash lookup + file read + write to socket]
(sub-millisecond, ~free, fails ~never)Lesson 8.4's micro-cache demo will show you 20 requests in 110 ms against an origin that takes 1+ ms per response — and that's with the origin on loopback. In the real world a hit replaces a 50–500 ms cross-service round trip. At scale, hit ratio is infrastructure cost, capacity headroom, and latency budget in one number — which is why Module 12 will end up graphing it on every dashboard.
But all that value is only available to requests that can safely be answered from a stored copy — and "safely" is what the contracts define.
The four caching actors — and who honors which contract: the
origin speaks all of it (Cache-Control, validators, Vary); your
proxy cache honors the shared-cache subset (public, s-maxage, X-Accel-Expires); the
browser honors the private subset (max-age, no-store, its own memory); a
CDN in between honors shared-cache semantics again. When a response misbehaves, name the actor first — “who is caching this?” is half the diagnosis.
2. Contract one: identity — what an entry is
A cache stores responses keyed by requests. The key question — "when is a stored response a valid answer to a new request?" — is the identity contract, and it's yours to write in NGINX:
proxy_cache_key "$scheme$proxy_host$request_uri";The default means: scheme + upstream host + full URI (query string included). Two consequences worth burning in now:
Everything that changes the response must be in the key — cookie variants, split-test buckets (Lesson 7.4!), and every other variant class (Lesson 8.2's patterns, with the
/cookie-key/proof).Everything in the key that doesn't change the response divides your hit ratio —
proxy_cache_keydesign is a minimization problem: the smallest key that still captures every response difference (Lesson 8.2's minimized-key idiom is the practice).
3. Contract two: freshness — how long it may be served
A fresh entry is served without consulting the origin; a stale one must be revalidated (contract three) or refused. The actors negotiate freshness in headers:
Header | Who sets it | Meaning |
|---|---|---|
| origin | "N seconds of freshness, starting at my
|
| origin | the same, for shared caches (you!) — overrides max-age |
| origin | the 1997 way; max-age wins when both exist |
| origin | NGINX-specific: overrides both of the above |
| you | the fallback when the origin said nothing |
The precedence rule to memorize (Lesson 8.3 proves it):
X-Accel-Expires
>
Cache-Control
/
Expires
>
proxy_cache_valid. A silent origin plus no
proxy_cache_valid means
nothing caches — the single most common "my cache doesn't work" report, and the first thing
X-Cache-Status: MISS-forever should suggest.
Two freshness flags origins use that you must honor deliberately:
no-cache (revalidate every time — cacheable but never blindly fresh) and
no-store (never persist).
private says "browsers may, shared caches may not" — NGINX respects it only if you let it (
proxy_ignore_headers Cache-Control exists for origins that lie; use it knowingly).
4. Contract three: validation — the cheap re-check
When freshness lapses, the cache and origin can avoid a full re-transfer using validators the origin sent with the first response:
cache ──▶ GET /item + If-None-Match: "etag-abc123" (or If-Modified-Since)
origin ──▶ 304 Not Modified (no body!)
cache serves the stored body, refreshed for another TTLA 304 round trip costs one RTT and zero body bytes — which is why origins that send
ETag/
Last-Modified compose beautifully with NGINX caches (with
proxy_cache_revalidate on (Lesson 8.3's opt-in) NGINX sends the stored validators and refreshes the entry on a 304; without it, an expired entry is simply refetched in full). Watch for it in the access log: a revalidation shows
EXPIRED in
$upstream_cache_status but tiny
$upstream_response_time — the contract working as designed.
5. What must never be cached
The contract's exceptions, so you never learn them from an incident:
Responses to requests with credentials (cookie-authenticated pages) — unless the key isolates them AND you've thought about shared-machine leakage. NGINX makes no Authorization-based caching decision of its own — it caches a credentialed response whenever the origin's headers allow it, and
proxy_ignore_headerscannot opt out (it only understands the X-Accel- /Expires/Cache-Control/Set-Cookie/Vary family). Keep credentials out of shared caches deliberately: put identity in the key, or gate withproxy_cache_bypass/proxy_no_cacheon$http_authorization.Personalized anything without key-level separation (the
/cookie-key/lesson, in reverse).Side-effectful responses — a
POSTthat returns a confirmation page: NGINX doesn't cache non-GET/HEAD by default; keep it that way.Set-Cookie-bearing responses — NGINX won't store them(strip the cookie first withproxy_ignore_headers Set-Cookiewhen the cookie is analytics noise, and the page becomes cacheable — a real trick for real sites).
6. The vocabulary for the rest of the module
Term | Meaning | Where |
|---|---|---|
entry | a stored response + its metadata | 8.2 |
key | the identity string | 8.2 |
TTL / freshness lifetime | how long an entry serves blindly | 8.3 |
revalidate | conditional origin check (304 dance) | 8.3 |
MISS / HIT / EXPIRED / STALE / UPDATING / BYPASS / REVALIDATED / NONE | the eight verdicts | 8.3 |
| the seven verdicts (plus an empty value, logged as
| 8.4 |
micro-cache | very-short-TTL cache for dynamic content | 8.4 |
slice | range-aligned chunked caching | 8.5 |
Try it yourself
Meet the instrument. Every response carries
X-Cache-Status; the log carriescache=. Make one request twice and read both:
curl -s -o /dev/null -D - -H 'Host: cdn.example.test' \
'http://127.0.0.1:8800/cached/cache/60?first=1' | grep -i x-cache
tail -1 /var/log/nginx/access.logSee the origin’s contract. The origin throughout this module is an echo backend — a ~20-line test server (direct on 127.0.0.1:9001, behind the edge on :8800) whose routes put the caching contract in the URL:
/cache/Nanswers withCache-Control: max-age=N,/inforeturns a JSON report,/microdoes real work (the dynamic-content stand-in), and/largefilesserves big bodies for the slicing lesson — an origin you control:
curl -s -D - -o /dev/null http://127.0.0.1:9001/cache/5 | grep -i cache-controlContract-one rehearsal. Before Lesson 8.2 formalizes it: predict whether
/cached/cache/60?a=1and/cached/cache/60?a=2share an entry. Test with the status header. Now predict/cookie-key/cache/60?xwith two cookie variants. Test.Feel the economics. Time 20 requests to
/micro/info?demo(~110 ms total in our capture) — then imagine the origin was 300 ms away instead of 1 ms.
Q&A
Q1. Why must the split-test bucket from Lesson 7.4 appear in the cache key?
Answer
Same URI, different variant content: without the bucket in the key, one variant's response serves the other's cohort — wrong content, poisoned experiments, and baffling bug reports. Key must capture every response difference.
Q2. Origin sends
Cache-Control: max-age=30. You set
proxy_cache_valid 200 5m. What's the effective TTL — and for whom?
Answer
30 seconds for NGINX (origin headers beat proxy_cache_valid; the directive is only a fallback for silent origins). The browser honors the same header for its own cache — both layers count 30s independently from the response's Date.
Q3. What exactly is cached for
GET /a after a 304 revalidation?
Answer
The stored body from the original 200 (304s carry none), with freshness reset for another TTL — validators make re-checks cheap, they don't re-transfer content.
Q4. An endpoint sets
Set-Cookie on every response and your hit ratio is zero. What's happening, and what's the careful fix?
Answer
NGINX declines to store responses bearing Set-Cookie (leakage protection). If the cookie is non-essential (analytics), strip it at the edge —
proxy_ignore_headers Set-Cookie — after confirming no session semantics ride on it; otherwise exclude the endpoint from caching and micro-cache upstream of personalization instead.
Takeaways
Cache hits replace origin round trips with sub-millisecond lookups; hit ratio is capacity, cost, and latency in one number.
Three contracts: identity (the key — your design decision), freshness (TTL — negotiated in headers, with a clear precedence), validation (the 304 dance — cheap re-checks via validators).
X-Accel-Expires>Cache-Control/Expires>proxy_cache_valid; a silent origin plus no directive caches nothing.Never cache: credentialed/personalized responses (without key isolation), side effects,
Set-Cookie-bearing bodies (unless deliberately stripped).The seven-verdict vocabulary (
MISS/HIT/EXPIRED/STALE/UPDATING/BYPASS/REVALIDATED + empty ("-" in logs)) is your debugging language for the rest of the module.
Thanks for your feedback.
Mark this learning resource complete to track your learning progress.