Web Services

HTTP Caching in Practice: Configuring Cache-Control and ETag Correctly

Misconfigured caching ends one of two ways: users never see updates, or origin traffic explodes. This guide covers the split between freshness and validation caching, the hashed-asset vs HTML strategy divide, plus ready-to-use Nginx config and verification commands.

By 巧匠 Team·8 min read·Updated 2026-08-31

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 Modified

Split 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-cache

Nginx 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                  = 协商命中