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.

Three-tier Cache-Control flow How max-age, s-maxage, and immutable map onto browser, edge, and origin caches. Cache-Control across three tiers Browser cache max-age + immutable CDN edge s-maxage + SWR Origin ETag validation no-store overrides all tiers; private confines storage to the browser. s-maxage tunes the edge without touching the browser lifetime. immutable removes the 304 round-trip entirely on hashed assets.

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].
  • curl 8+ 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.

bash
# 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 TypeRecommended DirectiveRationale
HTML Documentsmax-age=0, must-revalidate, no-cacheNavigation always checks for updated DOM structure and routing logic.
Hashed JS/CSSmax-age=31536000, public, immutableContent-hashed filenames guarantee uniqueness; browsers skip validation.
Unhashed Imagesmax-age=2592000, public30-day TTL balances freshness with CDN edge efficiency for media.
API JSONmax-age=300, private, no-transformShort 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.

json
{
  "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.

javascript
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.
nginx
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:

  1. Browser freshness check — if the asset is immutable and unexpired, this phase costs 0ms and no request leaves the device. This is the only phase you can fully eliminate.
  2. 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%.
  3. Conditional validation — the If-None-Match / If-Modified-Since round-trip when max-age has lapsed but the body may be unchanged. Budget: < 200ms; eliminate entirely for hashed assets.
  4. Origin transfer — full body retrieval, the most expensive phase. stale-while-revalidate hides 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.

bash
#!/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=31536000 to HTML or dynamic JSON breaks routing and personalization with no escape hatch.
  • no-cache vs no-store confusion: no-cache stores the response but requires validation before reuse; no-store skips caching entirely. Swapping them silently destroys the budget.
  • Missing Vary headers: omitting Vary: Accept-Encoding on 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.
  • immutable without hashing: setting immutable on a non-hashed filename pins users to an outdated asset until a manual purge.