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

Levels: Intermediate ~30 min read

Intermediate

What you will learn

  1. Why the edge wins: the economics of a cache hit.

  2. The three contracts: identity (what a cache entry is), freshness (how long it may be served), validation (how to cheaply re-check it).

  3. The four caching actors: origin, proxy cache, browser, CDN — who honors which contract.

  4. What must never be cached, and how the contract says so.

  5. The vocabulary you'll use for the rest of the module.


1. The economics

Every cache hit converts this:

text
client ──▶ [edge NGINX] ──▶ [app tiers] ──▶ [database] ──▶ disk
                                   (100ms+, real money, real failure modes)

into this:

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

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_key design 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

Cache-Control: max-age=N

origin

"N seconds of freshness, starting at my Date"

Cache-Control: s-maxage=N

origin

the same, for shared caches (you!) — overrides max-age

Expires: <date>

origin

the 1997 way; max-age wins when both exist

X-Accel-Expires: N

origin

NGINX-specific: overrides both of the above

proxy_cache_valid

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:

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

A 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_headers cannot 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 with proxy_cache_bypass/ proxy_no_cache on $http_authorization.

  • Personalized anything without key-level separation (the /cookie-key/ lesson, in reverse).

  • Side-effectful responses — a POST that 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 with proxy_ignore_headers Set-Cookie when 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

MISS / HIT / EXPIRED / STALE / UPDATING / BYPASS / REVALIDATED

the seven verdicts (plus an empty value, logged as -, when no cache is involved)

8.4

micro-cache

very-short-TTL cache for dynamic content

8.4

slice

range-aligned chunked caching

8.5

Try it yourself

  1. Meet the instrument. Every response carries X-Cache-Status; the log carries cache=. Make one request twice and read both:

bash
   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.log
  1. See 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/N answers with Cache-Control: max-age=N, /info returns a JSON report, /micro does real work (the dynamic-content stand-in), and /largefiles serves big bodies for the slicing lesson — an origin you control:

bash
   curl -s -D - -o /dev/null http://127.0.0.1:9001/cache/5 | grep -i cache-control
  1. Contract-one rehearsal. Before Lesson 8.2 formalizes it: predict whether /cached/cache/60?a=1 and /cached/cache/60?a=2 share an entry. Test with the status header. Now predict /cookie-key/cache/60?x with two cookie variants. Test.

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

Was this page helpful?

Mark this learning resource complete to track your learning progress.