HTTP Cache-Control Headers Explained
This guide sits within Advanced Caching Strategies & CDN Architecture and turns Cache-Control from a syntax reference into a measurable workflow.
Cache-Control is the single source of truth for cache lifecycle management across origin servers, edge networks, and client browsers. Misconfiguration degrades Time to First Byte (TTFB) past the 200ms boundary, inflates bandwidth bills, and opens cache-poisoning vectors. This guide prioritizes actionable configuration over theory: it moves from a metric baseline, to root-cause isolation in the Network panel, to targeted directive fixes, to CI-enforced validation. The actionable targets throughout are TTFB ≤ 200ms for cached assets, an edge hit ratio > 85%, and a measurable Largest Contentful Paint improvement once revalidation overhead is removed.
Problem Framing: When a 304 Is Already a Regression
A cached asset that still issues a conditional If-None-Match request is not free. Each hard reload or back/forward navigation pays for a TCP handshake, TLS negotiation, and origin CPU even when the body is a 304 Not Modified with zero payload. On a 3G/4G profile that round-trip adds 30-120ms of pure validation jitter to the critical path, which surfaces directly as LCP variance. The baseline you are fighting is not bytes transferred — it is the count of avoidable origin contacts. Target zero conditional requests for content-hashed assets and a validation latency under 200ms for the dynamic responses that genuinely must revalidate.
Prerequisites: Versions, Flags, and Tooling
Before changing a single directive, pin the surface you are configuring:
- Node 18+ with an Express 4.18+ static server, or Nginx 1.24+ for the reverse-proxy path.
- A build that emits content-hashed filenames — Vite 5+ (
rollupOptions.output) or Webpack 5 with[contenthash]. curl8+ for header introspection and a CI runner (GitHub Actions or GitLab CI) to host the audit script.- Chrome 120+ DevTools with the Network panel's Disable cache toggle accessible.
1. Environment Setup: Directive Precedence and Parsing
The Cache-Control header is a comma-separated list of directives that intermediaries and user agents resolve through a strict precedence hierarchy: no-store overrides all caching, private restricts storage to the local browser, and public permits shared caching across CDNs and proxies.
max-age defines freshness lifetime in seconds relative to the response timestamp. s-maxage overrides max-age exclusively for shared caches, letting you serve highly personalized content to users while aggressively caching identical payloads at the edge. must-revalidate forces the browser to contact the origin once max-age expires, preventing stale delivery during offline transitions.
# Establish a header baseline before you change anything.
curl -I -H "Accept: text/html" https://your-domain.com/
curl -I -H "Accept: application/json" https://your-domain.com/api/data
# trade-off: -I issues HEAD; if your origin handles HEAD differently from GET,
# switch to `curl -sD - -o /dev/null` to see the real GET response headers.
2. Capture Baseline: DevTools and the Age Header
Open the DevTools Network panel, leave Disable cache unchecked, and perform a hard reload. Filter by JS and CSS and record every hashed file returning a 304. Compare the Age header progression across two requests separated by a few seconds — a climbing Age confirms the edge is serving from cache; a reset Age on every hit means your directives never reached the edge.
Performance thresholds: target TTFB ≤ 200ms (ideally < 50ms) for cached static assets, and enforce max-age >= 31536000 (one year) for immutable resources to eliminate validation overhead.
3. Isolate Bottleneck: Map Directives to Asset Volatility
Uniform headers across all routes guarantee either stale delivery or wasted origin hits. Inject precise combinations at the route level instead.
| Asset Type | Recommended Directive | Rationale |
|---|---|---|
| HTML Documents | max-age=0, must-revalidate, no-cache | Navigation always checks for updated DOM structure and routing logic. |
| Hashed JS/CSS | max-age=31536000, public, immutable | Content-hashed filenames guarantee uniqueness; browsers skip validation. |
| Unhashed Images | max-age=2592000, public | 30-day TTL balances freshness with CDN edge efficiency for media. |
| API JSON | max-age=300, private, no-transform | Short TTL for dynamic data; private blocks shared caching of user payloads. |
When you align build-time hashing with the immutable flag, follow the dedicated walkthrough on setting up immutable cache headers for hashed assets to prevent stale bundle delivery.
{
"build": {
"rollupOptions": {
"output": {
"assetFileNames": "assets/[name]-[hash][extname]",
"chunkFileNames": "assets/[name]-[hash].js",
"entryFileNames": "assets/[name]-[hash].js"
}
}
}
}
The hash is what makes immutable safe. Trade-off: do not hash files referenced by stable external URLs (sitemaps, OG images, well-known paths) — the changing filename breaks consumers that expect a fixed location.
4. Apply Fix: Server and Proxy Configuration
Drive the directives from the layer closest to delivery. The Express path keeps logic in application code; the Nginx path keeps the origin process out of the hot path entirely.
app.use((req, res, next) => {
const path = req.path;
if (/\.[a-f0-9]{8,}\.(js|css)$/.test(path)) {
res.set('Cache-Control', 'public, max-age=31536000, immutable');
} else if (path.startsWith('/api/')) {
res.set('Cache-Control', 'private, max-age=300, no-transform');
} else if (path.endsWith('.html')) {
res.set('Cache-Control', 'no-cache, must-revalidate');
}
next();
});
// trade-off: the regex assumes an 8+ hex-char hash segment. A short or
// non-hex hashing scheme silently falls through to no caching — assert the
// pattern against real build output in CI before trusting it in production.
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Vary "Accept-Encoding";
access_log off;
}
location /api/ {
proxy_pass http://backend;
proxy_cache_valid 200 5m;
add_header Cache-Control "private, max-age=300, no-transform";
}
# trade-off: this block caches ALL js/css for a year, hashed or not. If any
# unhashed bundle slips through the build, users are pinned to a stale file
# until a manual purge — gate the location on a hash pattern if that risk exists.
Deconstructing the Cache Lifecycle into Timing Phases
A request that misses the browser cache walks four cost phases, each with its own budget and its own fix:
- Browser freshness check — if the asset is
immutableand unexpired, this phase costs 0ms and no request leaves the device. This is the only phase you can fully eliminate. - Edge lookup — governed by
s-maxage. A hit returns in single-digit milliseconds; a miss promotes the request to the origin. Budget: edge hit ratio > 85%. - Conditional validation — the
If-None-Match/If-Modified-Sinceround-trip whenmax-agehas lapsed but the body may be unchanged. Budget: < 200ms; eliminate entirely for hashed assets. - Origin transfer — full body retrieval, the most expensive phase.
stale-while-revalidatehides this behind a stale serve so the user never waits for it.
Fix the dominant phase first. For hashed bundles, phase 3 dominates and immutable deletes it. For marketing HTML, phase 4 dominates and stale-while-revalidate masks it.
Advanced Diagnostics: CDN Override and SWR Failure Modes
CDNs (Cloudflare, Fastly, CloudFront) and reverse proxies interpret Cache-Control but often apply their own override rules — many strip Set-Cookie and refuse to cache responses carrying authentication headers. To enforce origin directives at the edge, configure cache keys explicitly, disable dashboard TTL overrides, and use s-maxage for shared control. For provider-specific cache-key tuning and bypass rules on authenticated routes, see CDN edge caching configuration.
stale-while-revalidate decouples delivery from freshness validation: the browser serves cached content instantly while fetching an update in the background. Pair it with max-age to define the stale window — Cache-Control: public, max-age=3600, stale-while-revalidate=86400. Its failure mode is silent staleness when the background fetch fails repeatedly; pair it with the stale-while-revalidate implementation patterns and a stale-if-error fallback so a flapping origin does not pin users to old content. When immutable interacts with a service worker, ensure your service worker caching strategies bypass network fallback for hashed routes to prevent double-fetching.
The harder problem is changing content behind a URL you cannot rename — config blobs, unhashed JSON, edge HTML. That is a deletion problem, not a lifetime problem, and it is covered by the cache invalidation patterns workflow, which sequences tag-based purges against deploys so users never observe a half-updated asset graph.
Validation and Budgeting: A CI Header Assertion
Manual inspection does not scale across microservices and regions. Run a header audit on every deploy and fail the pipeline on drift.
#!/bin/bash
# cache-audit.sh — fail CI if a hashed asset lacks `immutable`.
set -euo pipefail
URLS=("https://example.com/" "https://example.com/static/main.abc12345.js" "https://example.com/api/config")
for url in "${URLS[@]}"; do
HEADERS=$(curl -sI "$url")
echo "=== $url ==="
echo "$HEADERS" | grep -iE "Cache-Control|Age|X-Cache-Status|ETag" || true
if [[ "$url" == *.js && "$url" =~ \.[a-f0-9]{8,}\.js ]]; then
echo "$HEADERS" | grep -qi "immutable" || { echo "FAIL: hashed asset missing immutable"; exit 1; }
fi
done
# trade-off: a hardcoded URL list rots as routes change. For large surfaces,
# derive URLs from the build manifest instead of maintaining this array by hand.
Correlate the result with field data: track the LCP delta in CrUX and your RUM beacon before and after the change, and assert the cache-policy audit in Lighthouse CI so a regression in directives blocks the merge rather than reaching production.
Common Implementation Pitfalls
- Over-caching HTML/APIs: applying
max-age=31536000to HTML or dynamic JSON breaks routing and personalization with no escape hatch. no-cachevsno-storeconfusion:no-cachestores the response but requires validation before reuse;no-storeskips caching entirely. Swapping them silently destroys the budget.- Missing
Varyheaders: omittingVary: Accept-Encodingon compressed content lets compressed and uncompressed responses collide in shared caches. - Dashboard TTL overrides: trusting CDN UI overrides without aligning origin headers produces inconsistency across edge nodes that is painful to debug.
immutablewithout hashing: settingimmutableon a non-hashed filename pins users to an outdated asset until a manual purge.
Related
- Advanced Caching Strategies & CDN Architecture frames where header tuning fits in the broader delivery stack.
- Setting up immutable cache headers for hashed assets is the deep dive on eliminating the 304 round-trip.
- CDN edge caching configuration covers s-maxage and edge cache keys provider by provider.
- Cache invalidation patterns handle the content you cannot rename behind a stable URL.
- Stale-while-revalidate implementation shows how to mask origin latency without serving permanently stale bodies.