backend / protocols / nginx / 08_caching.md

Nginx Caching

6 interview angles 6 min read source

Nginx Caching

proxy_cache makes nginx serve cached upstream responses without bothering your Python app. Cuts backend load dramatically for hot read endpoints. Configurable per-location, with sane fallbacks for stampedes and stale-while-revalidate.

Basic setup

http {
    # Define a cache zone — must be in `http` context
    proxy_cache_path /var/cache/nginx/api
                     levels=1:2
                     keys_zone=api_cache:50m
                     max_size=2g
                     inactive=60m
                     use_temp_path=off;

    server {
        location /api/items {
            proxy_pass http://backend;
            proxy_cache api_cache;
            proxy_cache_valid 200 5m;            # cache 200 responses for 5 minutes
            proxy_cache_valid 404 1m;            # cache 404s briefly
            proxy_cache_key "$scheme$request_method$host$request_uri";
            add_header X-Cache-Status $upstream_cache_status;   # HIT / MISS / BYPASS / STALE
        }
    }
}

proxy_cache_path parameters

Parameter Meaning
path filesystem dir for cached objects
levels=1:2 nest cache files in 1-char then 2-char subdirs (avoids huge flat dir)
keys_zone=name:size shared memory zone storing the cache index (50m ≈ ~400k keys)
max_size=Ng hard cap on disk usage; LRU eviction when exceeded
inactive=60m objects unused for this long are removed even if cache isn’t full
use_temp_path=off write directly to cache dir instead of staging — faster

Cache key

Default: $scheme$proxy_host$request_uri. Customize for your needs:

proxy_cache_key "$scheme$request_method$host$request_uri";

# Cache per-language:
proxy_cache_key "$scheme$request_uri$http_accept_language";

# Cache per-user (almost never useful — kills hit rate):
proxy_cache_key "$scheme$request_uri$cookie_session";

Make the key include everything that varies the response. Forgetting $request_method means a POST gets cached and returned for subsequent GETs (catastrophic).

What gets cached

By default nginx only caches GET and HEAD with status 200/301/302. Override:

proxy_cache_methods GET HEAD POST;        # add POST (rarely correct)
proxy_cache_valid 200 301 302 5m;
proxy_cache_valid 404 1m;
proxy_cache_valid any 30s;                # default for everything else

Honor upstream cache headers (Cache-Control: max-age=N, Expires) — proxy_cache_valid is the fallback when upstream doesn’t say.

What does NOT get cached

By default nginx skips caching when:

  • Upstream sends Set-Cookie (assumed user-specific).
  • Upstream sends Cache-Control: no-cache / private / no-store.
  • Upstream sends Vary: *.
  • Request has Cache-Control: no-cache or Pragma: no-cache from client.

To override (e.g. cache despite Set-Cookie):

proxy_ignore_headers Set-Cookie;
proxy_hide_header Set-Cookie;             # don't leak the cookie to other users

Be very careful — wrong here = cross-user data leakage.

X-Cache-Status for debugging

add_header X-Cache-Status $upstream_cache_status always;

Possible values:

Status Means
HIT served from cache
MISS not in cache; fetched from upstream and cached
BYPASS request bypassed cache (per proxy_cache_bypass)
EXPIRED cached but expired; refetched
STALE cached and expired but served stale (per proxy_cache_use_stale)
UPDATING served stale while a refresh is in progress
REVALIDATED upstream confirmed cached version still valid (304)
- not configured for caching

curl -I to check. Keep this header in production for debugging; remove in CDN if you don’t want to leak it.

Stale-while-revalidate (the cache stampede solution)

When 1000 requests for the same URL arrive simultaneously and the cached version just expired, naive caches stampede the upstream. Nginx prevents this:

proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_background_update on;
proxy_cache_lock on;
Directive What it does
proxy_cache_use_stale updating when upstream is being asked, serve stale to other concurrent requests
proxy_cache_use_stale error timeout http_500 ... when upstream is dead, serve stale to keep the site up
proxy_cache_background_update on refresh cache in background while serving stale
proxy_cache_lock on only one request at a time goes to the upstream for a given key (cache lock)
proxy_cache_lock_timeout 5s how long others wait on the lock before going themselves

This combination = “the upstream is hit at most once per key per cache period, even if 1000 clients hit at once.”

Microcaching — the cheap big win

For dynamic content that changes every few seconds at most:

proxy_cache_valid 200 1s;             # cache for ONE SECOND
proxy_cache_use_stale updating;
proxy_cache_lock on;

A 1-second cache on a 10k RPS endpoint reduces upstream traffic to ~1 RPS. For a busy news/social timeline endpoint that updates every few seconds, microcaching is a near-free 99.9% reduction. Almost no user notices.

Caveat: doesn’t work if responses are truly per-user (cookie-driven, JWT-driven). Microcache only the public, anon variants (gate with proxy_cache_bypass).

Per-request bypass

proxy_cache_bypass $cookie_nocache $arg_nocache $http_authorization;

Skip cache when:

  • Cookie nocache is set.
  • Query param nocache is present.
  • Authorization header is present (logged-in users get fresh content).

Purging (manual invalidation)

Open-source nginx doesn’t ship a purge command. Options:

  1. Delete from disk. rm -rf /var/cache/nginx/api and reload nginx. Crude but works.
  2. ngx_cache_purge third-party module — adds a proxy_cache_purge directive.
  3. NGINX Plusproxy_cache_purge built in.
  4. Wait it out — if TTLs are short (minutes), just wait.
  5. Versioned URLs — change ?v=2 to invalidate via cache key change.

Option 5 is the cleanest pattern for app-controlled invalidation.

FastCGI cache (PHP / FastCGI apps)

Same model, different prefix:

fastcgi_cache_path ...;
fastcgi_cache_key ...;
fastcgi_cache zone_name;
fastcgi_cache_valid 200 5m;

Equivalent for proxy_cache when the upstream is FastCGI (php-fpm).

Memory-only caching (faster but small)

By design proxy_cache writes to disk. For pure RAM caching, options:

  • Use a tmpfs (memory-backed filesystem) for the cache path.
  • For a few hot pages: proxy_cache_path /dev/shm/nginx_cache ....

Tradeoff: cache disappears on reboot; size limited by RAM.

Common pitfalls

  • Caching POST responses without explicitly opting in (and accepting the consequences). Don’t.
  • Cache key without $request_method — POST responses returned to GET clients.
  • Not setting Vary: Accept-Encoding when caching gzipped content — un-gzipped clients get garbage.
  • Caching responses with Set-Cookie by overriding proxy_ignore_headers Set-Cookie — without proxy_hide_header Set-Cookie, you leak one user’s session to everyone.
  • Forgot proxy_cache_lock — first request takes 100ms, the next 999 in that 100ms window all bypass cache and hit upstream.
  • TTL too long for fast-changing data — users see stale prices/inventory. Combine short TTL + stale-while-revalidate.

Common interview confusions

  • “Caching means the upstream is hit once per cache period.” — only with proxy_cache_lock. Without it, concurrent requests can all bypass the cache before the first finishes.
  • proxy_cache works for any HTTP method.” — defaults to GET/HEAD. POST etc. require explicit opt-in.
  • “Nginx automatically obeys Cache-Control: no-store.” — yes by default. Surprises happen when you’ve added proxy_ignore_headers Cache-Control.

Interview angle

  • “How would you cache responses with nginx?”proxy_cache_path to define a zone, proxy_cache <zone> in the location, proxy_cache_valid 200 5m; for TTL, custom proxy_cache_key if defaults don’t match what varies the response.
  • “What’s microcaching?” — caching dynamic content for very short windows (1–5s). On a 10k RPS endpoint, a 1s cache reduces backend load to ~1 RPS while users see content that’s at most 1 second stale.
  • “How do you avoid cache stampede?”proxy_cache_lock on so only one request fetches per key; proxy_cache_use_stale updating so others get stale while it does; proxy_cache_background_update on so refresh happens in background.
  • “How do you invalidate cached entries?” — versioned URLs (best); third-party ngx_cache_purge module; NGINX Plus’s purge directive; or just delete files from /var/cache/nginx/.
  • “What does X-Cache-Status tell you?” — HIT, MISS, BYPASS, EXPIRED, STALE, UPDATING — useful for debugging cache behavior in production.
  • “Why is proxy_cache_key important?” — it determines what counts as “the same request.” Missing $request_method or relevant headers can cause one user’s data being served to another.