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.
Cache-Control: public, max-age=0, s-maxage=600, stale-while-revalidate=60Caching 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
// 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.
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 GMTSplitting 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.
# 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 allKeeping 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.
// 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