HTTP Caching as Part of the Contract

max-age against s-maxage, the conditional GET that returns 304, and keeping an authenticated response out of a shared cache.

SyntaxCache-Control: public, max-age=0, s-maxage=600, stale-while-revalidate=60

Caching headers are not an optimisation bolted on afterwards. They are a statement about who may keep a copy of this response and for how long, and every CDN, browser and corporate proxy on the path will act on it. Get them wrong in the permissive direction and a shared cache serves one customer's invoice to another.

max-age against s-maxage

max-age applies to every cache, including the browser. s-maxage applies only to shared caches and overrides max-age for them. So Cache-Control: public, max-age=0, s-maxage=600 means "browsers revalidate every time, the CDN may serve this for ten minutes" — usually what you want for something you can purge on write. CDN-Cache-Control (RFC 9213) and vendor variants such as Cloudflare-CDN-Cache-Control target one tier without the browser ever seeing the directive.

The conditional GET

Return an ETag; the client sends it back as If-None-Match and you answer 304 Not Modified with no body. The round trip still happens, so latency is unchanged, but you save bandwidth and serialisation — worth it for large responses over mobile networks. stale-while-revalidate (RFC 5861) lets a cache serve a stale copy while refreshing behind it; do not pair it with s-maxage, which implies proxy-revalidate and forbids exactly that.

Authenticated responses

Vary: Authorization is not a safety mechanism. Cloudflare, by default, does not consider Vary in caching decisions beyond Accept-Encoding. Mark per-user responses private, or no-store when they must not be written to disk anywhere.

Example

Example · javascript
// routes/catalogue.js
import crypto from 'node:crypto';

// Public and cacheable. No s-maxage here on purpose: s-maxage implies
// proxy-revalidate, which would forbid the stale-while-revalidate below.
router.get('/products/:sku', async (req, res) => {
  const product = await catalogue.get(req.params.sku);
  if (!product) return res.status(404).end();

  const body = JSON.stringify(product);
  // Strong ETag over the exact bytes we would send. Express's own ETag is
  // weak by default, which is useless for If-Match (see concurrency).
  const etag = `"${crypto.createHash('sha256').update(body).digest('base64url').slice(0, 27)}"`;

  // The object form of res.set() is shorthand for three res.setHeader() calls.
  res.set({
    ETag: etag,
    'Cache-Control': 'public, max-age=60, stale-while-revalidate=60',
    'Content-Type': 'application/json',
  });

  // Conditional GET. A real implementation splits the field on ',' and
  // handles '*', since If-None-Match may carry a list.
  const inm = req.get('If-None-Match');
  if (inm && inm.split(',').some((t) => t.trim() === etag || t.trim() === '*')) {
    return res.status(304).end();         // 304 carries the validators, no body
  }
  res.send(body);
});

// Per-user. Vary: Authorization is NOT sufficient; say private.
router.get('/me/invoices', authenticate, async (req, res) => {
  res.set('Cache-Control', 'private, no-store');
  res.json(await invoices.listFor(req.principal.userId));
});

When to use it

  • Cutting origin traffic for a product catalogue that changes a few times a day
  • Shrinking a large list response for mobile clients without changing its shape
  • Making sure an authenticated endpoint is never stored by a CDN or corporate proxy
  • Serving slightly stale data during an origin outage instead of erroring

More examples

The conditional GET on the wire

Worth measuring before adopting: 304s help most on large list responses over mobile links and help almost nothing on a 300-byte document, where the headers cost more than the body. The Last-Modified note is why ETags win on anything that can change twice in a second.

Example · http
GET /products/SKU-9001
HTTP/1.1 200 OK
ETag: "k7Qd2mXr"
Cache-Control: public, max-age=60, stale-while-revalidate=60
Content-Length: 4812

{"sku": "SKU-9001", ...}

# 61 seconds later the browser revalidates rather than guessing.
GET /products/SKU-9001
If-None-Match: "k7Qd2mXr"

HTTP/1.1 304 Not Modified
ETag: "k7Qd2mXr"
Cache-Control: public, max-age=60, stale-while-revalidate=60
# No body. 4812 bytes saved; the round trip still happened.

# Last-Modified / If-Modified-Since is the older pair. Second granularity,
# so two writes in the same second are indistinguishable. Prefer ETag.
GET /products/SKU-9001
If-Modified-Since: Thu, 02 Oct 2026 09:14:00 GMT

Splitting browser and CDN policy

Use the max-age=0 with s-maxage shape for anything you can purge, and the targeted headers when a CDN TTL would be absurd in a browser that cannot be purged. The last two lines cause real incidents: teams reach for no-cache meaning 'do not keep this', and a copy of the response is sitting in a disk cache.

Example · http
# One header, two audiences. Browsers must revalidate; the CDN holds it
# for ten minutes, so a purge on write is what keeps it correct.
Cache-Control: public, max-age=0, s-maxage=600

# Targeted directives (RFC 9213) when you want the tiers to disagree without
# the browser seeing the CDN's rules at all.
Cache-Control: public, max-age=60
CDN-Cache-Control: public, max-age=86400
Cloudflare-CDN-Cache-Control: public, max-age=86400

# Immutable build assets: hashed filename, so the content can never change.
Cache-Control: public, max-age=31536000, immutable

# The pair that is not interchangeable:
Cache-Control: no-cache   # may be stored, but must be revalidated before reuse
Cache-Control: no-store   # must not be written down at all

Keeping a private response out of a shared cache

Three named policies beat per-route header strings because the review question becomes 'is this public, user or secret?' rather than 'is this Cache-Control line correct?'. Add a test that fails when an authenticated route answers without `private` or `no-store` — that is the check that catches the leak before a CDN does.

Example · javascript
// A single middleware, so it cannot be forgotten on one route.
app.use((req, res, next) => {
  res.on('headersSent-ish', () => {});   // illustrative hook point
  next();
});

function cachePolicy(scope, seconds = 0) {
  switch (scope) {
    case 'public':
      // Safe to share: no user-specific data in the body at all.
      return `public, max-age=${seconds}, stale-while-revalidate=${seconds}`;
    case 'user':
      // Browser may keep it for this user; no shared cache may.
      // Vary: Authorization is NOT a substitute -- Cloudflare ignores Vary
      // by default apart from Accept-Encoding.
      return `private, max-age=${seconds}, must-revalidate`;
    case 'secret':
      // Must not be written to disk anywhere: tokens, one-time codes, PII.
      return 'no-store';
  }
}

router.get('/pricing', (req, res) => {
  res.set('Cache-Control', cachePolicy('public', 300)).json(publicPricing);
});

router.get('/me/profile', authenticate, async (req, res) => {
  res.set('Cache-Control', cachePolicy('user', 30))
     .json(await users.profile(req.principal.userId));
});

router.post('/sessions', async (req, res) => {
  res.set('Cache-Control', cachePolicy('secret'))
     .status(201).json({ token: await sessions.create(req.body) });
});

Discussion

  • Be the first to comment on this lesson.