Two kinds of caches, one set of directives
HTTP caches come in two kinds: the browser’s private cache and shared CDN/Nginx caches, both governed by Cache-Control directives. Get the direction wrong and you either serve stale content forever or drown the origin in traffic.
Set the goal first: content-fingerprinted static assets want long freshness caching, while HTML and APIs want per-request validation — two very different strategies that must be configured separately.
# 响应头速查(下文逐个展开)
# Cache-Control: public, max-age=31536000, immutable 静态资源
# Cache-Control: no-cache HTML/接口(可缓存但需校验)
# Cache-Control: no-store 敏感数据,禁止缓存Freshness caching: every Cache-Control directive explained
`max-age=N` serves from the local cache with zero requests for N seconds; `s-maxage` constrains shared caches only and takes precedence; `public`/`private` decides CDN storability; `immutable` tells browsers the resource never changes (for fingerprinted URLs — even reloads skip validation).
The two most misunderstood: `no-cache` does NOT mean "do not cache" — it means "must revalidate before reuse"; `no-store` is the true "never cache". Expires is an HTTP/1.0 absolute-time relic overridden by Cache-Control; modern config uses only the latter.
Cache-Control: public, max-age=31536000, immutable
# 指纹资源:一年强缓存 + 永不校验
Cache-Control: private, max-age=0, must-revalidate
# 私有内容:每次都校验Validation caching: ETag over Last-Modified
Once freshness expires, validation kicks in: the browser asks "has it changed?" with `If-None-Match: <ETag>` (or `If-Modified-Since`). Unchanged means 304 with no body (near-zero traffic); changed means 200 plus new content.
Prefer ETag: it identifies content, while Last-Modified only has second-level precision and a changed mtime does not imply changed content. Nginx auto-generates ETags for static files (mtime+size); dynamic responses need the application to compute one (a content hash is the robust choice).
# 手动模拟一次协商
curl -sI https://laohand.com/index.html | grep -i etag
curl -sI -H "If-None-Match: <上一步拿到的值>" https://laohand.com/index.html | head -1
# 期望输出: HTTP/1.1 304 Not ModifiedSplit strategy: fingerprinted assets vs HTML
Build artifacts carry a content hash (app.a1b2c3.js): any content change changes the filename, so each URL maps to exactly one content forever — safely served with `max-age=31536000, immutable`. HTML references those hashed assets, so HTML itself must be `no-cache` (revalidate every time) for users to see new releases promptly.
Unfingerprinted static files use a short `max-age` (say 5 minutes) with validation as backstop; API responses default to `private, no-cache`, granting `public` only to clearly cacheable public list data. This split is the only way to get both instant loads and immediate releases.
# 典型分治
/app/assets/* → public, max-age=31536000, immutable
/index.html → no-cache
/api/* → private, no-cacheNginx implementation
Split by location: fingerprinted asset directories get the year-long cache; HTML gets an explicit `no-cache` (note `expires -1` emits no-cache semantics); proxied backend headers are passed through or overridden as needed. ETag is on by default for Nginx static files.
With a CDN, add `s-maxage` for the shared cache while the origin keeps a shorter `max-age` for browsers — each cache layer expires independently.
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
location = /index.html {
add_header Cache-Control "no-cache";
}
# 静态文件 ETag 默认开启,无需额外配置Verification and common pitfalls
Verification chain: `curl -I` inspects headers; in DevTools Network, a Size column showing (memory cache)/(disk cache) means a freshness hit, while 304 means a validation hit.
Two frequent pitfalls: ① with DevTools open, the Network panel ticks Disable cache by default, so results do not reflect real users; ② the CDN has its own cache layer — after changing origin headers you must also purge the CDN. "Users never see the new release" is nine-times-out-of-ten HTML being freshness-cached: set HTML to no-cache and it is cured for good.
curl -sI https://example.com/app.a1b2.js | grep -i cache-control
# DevTools: Network → Size 列
# (memory/disk cache) = 强缓存命中
# 304 = 协商命中