Setting Cache-Control Headers for Static and Dynamic Content

The Cache-Control header is the single most consequential directive you control for CDN performance. Get it right and hashed assets serve from edge memory for a year while authenticated pages never leak between users. Get it wrong and you either revalidate every request (killing hit ratio) or cache a logged-in dashboard at the edge (a security incident). This guide gives you a per-content-class header policy, shows you how to set it at origin (nginx, Express) and override it at the Cloudflare edge, and how to verify every decision with curl -I. After reading you will be able to assign correct caching directives to any response your stack emits.

Key objectives:

  • Map each content class — immutable assets, revalidating HTML, private responses, API JSON — to a precise set of directives.
  • Set headers authoritatively at origin and override them deterministically at the edge.
  • Distinguish browser-facing max-age from CDN-facing s-maxage and the role of private/no-store.
  • Verify caching decisions with cache-control, cf-cache-status, and age response headers.
Cache-Control decision flow by content class A decision tree that routes responses to immutable, revalidate, private, or API caching directives based on whether content is per-user and whether its URL is content-hashed. Response to cache? inspect the URL + auth Per-user / authed? Set-Cookie, JWT Shared / public? same for all users Private response private, no-store never at shared edge Hashed asset max-age=31536000 immutable HTML / API no-cache or short s-maxage Always pair with a deliberate Vary header Vary: Accept-Encoding only, unless content truly varies

Prerequisites and environment

You need shell access to your origin (nginx 1.20+ or Node 18+ with Express 4) and, for edge overrides, a Cloudflare zone with API token scoped to Zone.Cache Rules:Edit plus optional Workers access. Confirm your tooling:

nginx -v          # nginx version: nginx/1.24.0
node -v           # v20.11.0
curl --version    # curl 8.5.0
wrangler --version # 3.x

Throughout, remember the split-brain nature of these directives. max-age is read by browsers and any cache. s-maxage overrides max-age for shared caches only (CDNs, proxies) and is invisible to the browser. Cloudflare honors s-maxage over max-age for its edge TTL when origin cache control is respected. This split lets you cache HTML at the edge for 60 seconds while telling browsers to always revalidate.

Step 1: Define the content-class policy

Adopt this matrix as your source of truth. Every response your stack emits must fall into exactly one row.

Content class URL shape Cache-Control Why
Hashed static asset /app.4f3a1c.js public, max-age=31536000, immutable Filename changes on every deploy; safe to cache forever
Unhashed static /favicon.ico public, max-age=86400 Stable but may change; daily revalidation
HTML document /, /pricing public, no-cache or public, s-maxage=60, max-age=0 Must reflect deploys instantly or near-instantly
API JSON (shared) /api/products public, s-maxage=30, stale-while-revalidate=120 Cacheable but volatile
API JSON (per-user) /api/me private, no-store Contains user data; never cache anywhere
Authenticated page /dashboard private, no-store Per-user; must never reach a shared cache

Two directives carry outsized weight. immutable tells browsers not to send a conditional revalidation request even on reload, eliminating round-trips for assets. no-cache does not mean “do not cache” — it means “cache, but revalidate before every use,” which is exactly what you want for HTML behind a versioned ETag. The true opt-out is no-store.

Step 2: Set headers at origin with nginx

nginx maps directives by location and file extension. Hashed assets get the immutable treatment; everything else revalidates.

# Hashed build artifacts: fingerprinted, safe to freeze for a year
location ~* "-[0-9a-f]{8}\.(js|css|woff2|png|svg)$" {
    add_header Cache-Control "public, max-age=31536000, immutable";
    add_header Vary "Accept-Encoding";
    try_files $uri =404;
}

# HTML: always cache at the edge briefly, never trust the browser copy
location ~* \.html$ {
    add_header Cache-Control "public, s-maxage=60, max-age=0, must-revalidate";
}

# Authenticated API: opt out of every cache layer
location /api/me {
    add_header Cache-Control "private, no-store";
    proxy_pass http://app_upstream;
}

Reload and watch for syntax errors:

nginx -t && nginx -s reload
# nginx: configuration file /etc/nginx/nginx.conf test is successful

Side effect to know: nginx add_header only fires for 200, 201, 204, 206, 301, 302, 303, 304, 307, 308 by default. For error responses or to force it, append always. If your app upstream already emits Cache-Control, nginx add_header appends a second header rather than replacing it — use the headers-more module’s more_set_headers to overwrite cleanly.

Step 3: Set headers at origin with Express

In Node, set headers per-route. Keep the immutable static handler separate from dynamic routes.

const express = require('express');
const app = express();

// Hashed assets: one year, immutable
app.use('/static', express.static('dist', {
  immutable: true,
  maxAge: '365d',
  setHeaders(res) {
    res.setHeader('Vary', 'Accept-Encoding');
  },
}));

// HTML shell: edge caches 60s, browser always revalidates
app.get('/', (req, res) => {
  res.set('Cache-Control', 'public, s-maxage=60, max-age=0');
  res.sendFile('index.html', { root: 'dist' });
});

// Per-user JSON: never store
app.get('/api/me', requireAuth, (req, res) => {
  res.set('Cache-Control', 'private, no-store');
  res.json({ id: req.user.id, plan: req.user.plan });
});

// Shared JSON: short shared TTL plus background refresh
app.get('/api/products', (req, res) => {
  res.set('Cache-Control', 'public, s-maxage=30, stale-while-revalidate=120');
  res.json(catalog);
});

The stale-while-revalidate directive on /api/products lets the edge serve a slightly stale body for up to 120s while it refreshes asynchronously, smoothing origin load — see serving stale content with stale-while-revalidate for the full pattern and its failure modes.

Step 4: Override at the Cloudflare edge

Origin headers are the source of truth, but you often need the edge to override them — for example to cache HTML that your origin marks no-cache, or to force a long edge TTL on an asset path you do not control. Two mechanisms exist: declarative Cache Rules and a Worker.

Cache Rules (declarative)

Cache Rules set Edge TTL and Browser TTL independently of origin headers. Create one via API that caches everything under /static/ for a year:

curl -X POST \
  "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/rulesets/phases/http_request_cache_settings/entrypoint" \
  -H "Authorization: Bearer $CF_API_TOKEN" \
  -H "Content-Type: application/json" \
  --data '{
    "rules": [{
      "expression": "(starts_with(http.request.uri.path, \"/static/\"))",
      "action": "set_cache_settings",
      "action_parameters": {
        "cache": true,
        "edge_ttl": { "mode": "override_origin", "default": 31536000 },
        "browser_ttl": { "mode": "override_origin", "default": 31536000 }
      }
    }]
  }'

override_origin makes the edge ignore the origin Cache-Control entirely for TTL purposes. Use respect_origin if you want origin headers to win. For HTML, set a short edge_ttl (60) with browser_ttl mode bypass so visitors always revalidate while the edge absorbs traffic spikes.

Worker (programmatic)

When the decision is dynamic — say, strip Set-Cookie and cache only when no auth cookie is present — a Worker gives you full control:

export default {
  async fetch(request, env, ctx) {
    const response = await fetch(request);
    const url = new URL(request.url);
    const headers = new Headers(response.headers);

    if (/-[0-9a-f]{8}\.(js|css|woff2)$/.test(url.pathname)) {
      headers.set('Cache-Control', 'public, max-age=31536000, immutable');
    } else if (url.pathname.endsWith('.html') || url.pathname === '/') {
      headers.set('Cache-Control', 'public, s-maxage=60, max-age=0');
    }
    headers.set('Vary', 'Accept-Encoding');

    return new Response(response.body, {
      status: response.status,
      headers,
    });
  },
};

Be deliberate with Vary: a Worker that blindly forwards an origin Vary: Cookie or Vary: User-Agent will fragment the cache into thousands of near-identical entries. Pin it to Accept-Encoding unless the body genuinely differs, and read customizing cache keys to improve hit ratio before adding any other dimension.

Verification

Inspect the live headers with curl -I and read three fields: cache-control (the policy), cf-cache-status (whether the edge served from cache), and age (seconds since the edge fetched from origin).

curl -sI https://example.com/static/app.4f3a1c.js | grep -iE 'cache-control|cf-cache-status|age'
# cache-control: public, max-age=31536000, immutable
# cf-cache-status: HIT
# age: 84213

A hashed asset should show HIT and a growing age on repeat requests. For HTML, expect a short-lived edge cache:

curl -sI https://example.com/ | grep -iE 'cache-control|cf-cache-status'
# cache-control: public, s-maxage=60, max-age=0
# cf-cache-status: HIT      # DYNAMIC on first request, HIT within the 60s window

A private response must always bypass the shared cache:

curl -sI -H "Cookie: session=abc" https://example.com/api/me | grep -iE 'cache-control|cf-cache-status'
# cache-control: private, no-store
# cf-cache-status: BYPASS

Interpret cf-cache-status precisely: HIT served from edge, MISS fetched from origin and stored, DYNAMIC not eligible for caching (no positive directive), BYPASS a rule or directive forced an origin fetch, EXPIRED/REVALIDATED the entry aged out and was refreshed.

Troubleshooting

HTML got cached and shows a stale deploy

Symptom: users see the previous build after a deploy. Cause: HTML was served with a positive max-age (or a Cache Rule force-cached it) and the edge holds it for the full TTL.

curl -sI https://example.com/ | grep -i cache-control
# cache-control: public, max-age=600   <-- wrong: browser+edge hold for 10 min

Fix: HTML must carry max-age=0 (or no-cache) so the browser revalidates, and only a short s-maxage for the edge. Purge the bad entries on deploy via the purge API and re-fetch to confirm cf-cache-status: MISS followed by a fresh body.

Assets keep revalidating instead of serving from cache

Symptom: every page load issues 304 Not Modified requests for JS/CSS even though they never change. Cause: the asset lacks immutable, so browsers send conditional If-None-Match requests on reload.

curl -sI -H 'If-None-Match: "abc"' https://example.com/static/app.4f3a1c.js | head -1
# HTTP/2 304   <-- a wasted round-trip on every reload

Fix: add immutable to fingerprinted assets. With public, max-age=31536000, immutable, browsers skip revalidation entirely until the URL (hash) changes. Confirm the directive is present and that the build genuinely fingerprints filenames.

Cache hit ratio collapsed after adding Vary

Symptom: cf-cache-status is mostly MISS; edge storage balloons. Cause: a Vary: Cookie, Vary: User-Agent, or Vary: Accept-Language header forces the cache to key on that header, creating a near-unique entry per visitor.

curl -sI https://example.com/api/products | grep -i vary
# vary: Accept-Encoding, Cookie   <-- Cookie shatters the cache

Fix: remove uncontrolled dimensions from Vary. Keep Accept-Encoding (needed for compression negotiation) and drop the rest at the edge by overwriting the header in a Cache Rule or Worker. If responses truly differ by language, normalize to a small set of cache keys rather than the raw header.

Private response leaked into the shared cache

Symptom: one user sees another user’s data. Cause: a per-user response was sent with public or with no directive while a force-cache Cache Rule applied. This is a security incident — treat it as such.

Fix: ensure every authenticated route sends private, no-store, exclude authenticated paths from any override_origin Cache Rule (not http.cookie contains "session"), purge the affected paths immediately, and verify with an authenticated curl that cf-cache-status reports BYPASS.

Frequently Asked Questions

What is the difference between no-cache and no-store? no-cache permits storage but requires revalidation with the origin before every reuse, which is ideal for HTML behind an ETag. no-store forbids writing the response to any cache at all and is the correct choice for private, per-user data.

Why use s-maxage instead of just max-age for CDN caching? s-maxage applies only to shared caches like a CDN, so you can cache HTML at the edge for 60 seconds while sending the browser max-age=0 to force revalidation. A single max-age would apply the same TTL to both the browser and the edge.

Does immutable actually do anything if max-age is already a year? Yes. Without immutable, browsers still issue a conditional revalidation request when the user reloads the page, costing a round-trip even on a cache hit. immutable suppresses that request entirely until the URL changes.

How do I cache HTML safely without serving a stale deploy? Give HTML a short edge TTL via s-maxage (30 to 60 seconds) with max-age=0 for browsers, and purge the HTML paths from the edge as part of your deploy pipeline so the next request repopulates the cache from the new build.

Why does cf-cache-status show DYNAMIC for my cacheable response? DYNAMIC means Cloudflare did not consider the response eligible for caching, usually because no positive Cache-Control directive was present, a Set-Cookie header was returned, or the request method was not GET. Add an explicit public directive or a Cache Rule that force-caches the path.

Back to Cache-Control & CDN TTL