Caching Slowly-Updating Dynamic Content: Strategy and an Nginx Configuration Guide
Article comments, leaderboards, poll results — this kind of content is dynamically generated but updates infrequently, and hitting the backend on every request is wasteful. Caching them at the Nginx proxy layer noticeably eases backend pressure and speeds up page loads. This post runs the full configuration hands-on in an nginx:1.28 container: cache hits, expiry, the fallback when the backend dies, and one cache-key mistake that lets users read other people’s data.
What Content Suits This Kind of Caching
There is only one criterion: dynamically generated, but updating on the scale of minutes or hours. Article comments, leaderboards, and poll results all qualify — computing them in real time per request is waste. The reverse also holds: balance and order-status data, where freshness is critical, do not qualify — the consistency cost of caching them outweighs the gain.
Caching Strategy
For this class of content, the recommended combination is two layers: a longer cache duration + active invalidation on update. The overwhelming majority of user requests hit the cache, and after content changes, the invalidation step guarantees timely visibility. The cache duration defines “how stale the data a user reads can be in the worst case,” while the invalidation mechanism is where freshness actually comes from — you need both.
Nginx Configuration Example
Cache path and key (http block):
1 | proxy_cache_path /data/nginx/cache levels=1:2 keys_zone=my_cache:10m |
keys_zone=my_cache:10m sets aside 10MB of shared memory for cache keys and metadata; max_size=1g caps the cache’s total size; inactive=7d clears an entry after 7 days without access; use_temp_path=off writes cache files straight into their final directory, saving one copy.
Proxy and cache rules (server block):
1 | location /dynamic-content/ { |
proxy_cache_valid 200 1h caches 200 responses for 1 hour; proxy_cache_use_stale is the backend-failure fallback — on backend errors, timeouts, or 5xx, stale cache keeps being served; and the X-Cache-Status header exposes each request’s cache state — the single most important observation point when tuning.
Example 1: The MISS, HIT, and EXPIRED States
Container verification (the backend echoes content by query parameter; TTL set to 3 seconds for easy observation; whether the backend was touched cross-checked against its access.log line count), requesting the same URL repeatedly and watching the response headers:

The first request MISS (fetched from the backend and written into cache), the second HIT (served from cache; the content’s timestamp unchanged — the backend was never touched); after the 3-second TTL passed, the next request turned EXPIRED (cache expired, refetched from the backend and refreshed). The common values of X-Cache-Status are these four: MISS, HIT, EXPIRED, plus the STALE demonstrated next.
Example 2: Backend Down, use_stale Catches the Fall
Kill the backend process, then request the same URL again — the cache is already expired and the origin fetch is bound to fail, so by ordinary expectations this should be a 502:

What came back, as verified, was STALE: Nginx tried the origin and failed (the error condition matched proxy_cache_use_stale), and kept serving the already-expired old cache to the user. That is exactly what this configuration buys: during backend flaps, restarts, and deploys, users read at most a few-seconds-old data instead of an error page. For comments and leaderboards, that is a perfectly acceptable degradation.
The Wrong Way Compared: A Cache Key Missing Parameters Serves One User Another’s Data
The cache key defines “what counts as the same request.” Write the key without the query string ($uri instead of $request_uri):
1 | # Wrong: the cache key carries no query string |
The verified consequence:

First request ?id=1 (MISS, returning id=1’s data), then request ?id=2 — the response was id=1’s data, with status HIT. Two different requests were treated as one, and user A’s data leaked to user B. On any endpoint carrying a login state, this class of error is a data-breach incident; and this is the foundation of why the key must be built on $request_uri (complete, parameters included).
Caveats
- Match cache duration to update frequency: the example’s 1 hour means “users may read data up to 1 hour old” — set it only if you can accept that; pair it with “active invalidation on update” to keep freshness.
- Active invalidation does not come built-in: you need a purge module or a self-built invalidation script (deleting cache files by key / calling an interface). Prepare it before launch, or users won’t see updated content.
- use_stale is degradation, not the norm: it serves old content, so use it cautiously on freshness-sensitive paths; adding the
updatingparameter also prevents a thundering herd of concurrent origin fetches at the moment a cache entry expires. - Watch X-Cache-Status after launch: a persistently low hit rate — check the cache key and TTL first, then check whether response headers like Set-Cookie are marking responses uncacheable.
Summary
Back to the opening scenario: for low-frequency-update content like comments and leaderboards, the three-part proxy_cache configuration (cache zone, cache key, cache rules) strips most repeated requests off the backend. The hands-on essentials: X-Cache-Status is your eyes (the four states MISS/HIT/EXPIRED/STALE correspond to four distinct paths), use_stale is the safety net when the backend fails, and the cache key must carry the full request parameters — omit them and you have a cross-user data-bleed incident.
This article was rebuilt from the author’s CSDN blog posts written between 2020 and 2024, originally published on CSDN.