Tuning Cache-Control max-age for Edge Caches
This guide is part of Stale-While-Revalidate at the Edge. It focuses on one decision: picking the actual numbers for max-age, s-maxage, and stale-while-revalidate so an edge cache is fast without serving dangerously stale content — and on why the browser cache and the CDN cache should almost never share the same window.
The problem
A team sets Cache-Control: public, max-age=3600 on an API route to cut origin load. It works — until they ship a fix and users keep seeing the broken response for up to an hour, because the value was also cached in every browser and there is no way to purge a browser. The opposite failure is just as common: max-age=0, no-cache everywhere, and the origin buckles under traffic that should never have reached it. The root issue is treating “cache lifetime” as a single number when it is really three independent windows controlling three different caches.
Root cause: one directive, several caches
A single Cache-Control header is interpreted by every cache in the path — the browser, any shared proxy, and the CDN’s tiered edge fleet. max-age binds all of them; s-maxage overrides max-age for shared caches only; stale-while-revalidate extends servability past freshness for caches that support it. The CDN is purgeable in seconds and globally controlled, so it can hold content aggressively. The browser is unpurgeable and per-user, so it must hold content conservatively. Tuning means giving each cache the window its purgeability justifies — which requires s-maxage (and ideally CDN-Cache-Control) to split them, exactly the surface the stale-while-revalidate pattern builds on.
The tuning workflow
Step 1 — Classify the content by tolerable staleness
Decide the maximum time a user may see an old version. This is a product decision, not a technical one. A blog post tolerates minutes; a stock price tolerates seconds; an immutable build asset tolerates a year. Write the number down — it caps every window that follows.
Step 2 — Set the CDN window from purgeability, not staleness
Because the CDN is purgeable, s-maxage can be far longer than the tolerable-staleness number as long as you also wire on-demand purging. Set a generous s-maxage for origin protection and rely on tag-based invalidation to cut it short when content actually changes.
// Long CDN window, protected by tag purges, with a stale window for latency.
const cdn = "public, s-maxage=600, stale-while-revalidate=3600, stale-if-error=86400";
Step 3 — Set the browser window conservatively
The browser cannot be purged, so max-age must never exceed your tolerable-staleness number. For dynamic API responses, prefer max-age=0, must-revalidate and let the CDN absorb load; for static hashed assets, the browser window can be very long because the URL changes on every deploy.
// Dynamic API: browser always revalidates; CDN does the heavy lifting.
const browser = "public, max-age=0, must-revalidate";
// Immutable hashed asset: browser may cache for a year.
const asset = "public, max-age=31536000, immutable";
Step 4 — Emit split directives
Combine the two windows with CDN-Cache-Control for the edge and Cache-Control for the browser. Where a provider lacks CDN-Cache-Control, fall back to s-maxage, which shared caches honor over max-age.
function cacheHeaders(): Headers {
const h = new Headers();
h.set("CDN-Cache-Control", "public, s-maxage=600, stale-while-revalidate=3600");
h.set("Cache-Control", "public, max-age=0, must-revalidate");
return h;
}
Step 5 — Size the stale-while-revalidate window
Set stale-while-revalidate to cover the gap between origin-fetch latency spikes and the next natural request. Too short and the stale window closes before a revalidation completes, forcing blocking fetches; too long and a slow content drift goes unnoticed. A practical starting point is stale-while-revalidate = 5 × s-maxage, then tune from hit-ratio data.
Step 6 — Measure and iterate
Read the provider cache-status header (cf-cache-status, x-vercel-cache, Cache-Status) per route and watch the hit ratio. Raise s-maxage while origin load is high and the hit ratio is below target; lower it if you observe staleness complaints that purging cannot resolve fast enough.
Config snippet
Per-route windows for a Cloudflare Worker, keyed by content class:
const POLICY: Record<string, string> = {
static: "public, max-age=31536000, immutable",
api: "public, s-maxage=60, stale-while-revalidate=300, stale-if-error=86400",
html: "public, s-maxage=300, stale-while-revalidate=3600",
};
function policyFor(pathname: string): string {
if (pathname.startsWith("/assets/")) return POLICY.static;
if (pathname.startsWith("/api/")) return POLICY.api;
return POLICY.html;
}
Local vs production divergence
| Concern | Local dev | Production CDN |
|---|---|---|
s-maxage honored |
No shared cache present | Yes, by the edge fleet |
Browser max-age |
Honored by your browser | Honored by all clients |
stale-while-revalidate |
Not exercised | Drives background refresh |
| Cache-status header | Usually absent | HIT/MISS/STALE/EXPIRED |
| Purge latency | Instant (no cache) | Provider SLA, typically under 5s |
Vitest validation
Assert that dynamic routes never leak a long browser window — the most damaging tuning mistake.
// cache-policy.test.ts
import { describe, it, expect } from "vitest";
import { policyFor } from "./policy";
describe("policyFor", () => {
it("keeps the browser window short for API routes", () => {
const api = policyFor("/api/products");
expect(api).toContain("s-maxage=60");
// Browser window is governed separately; API s-maxage must not imply long max-age.
expect(api).not.toContain("max-age=31536000");
});
it("allows a long immutable window only for hashed assets", () => {
expect(policyFor("/assets/app.abc123.js")).toContain("immutable");
});
});
Pitfalls
- Long
max-ageon dynamic content. Browsers cache the bug with no purge path. Fix:max-age=0, must-revalidatefor dynamic, long windows only for hashed assets. - Relying on
max-ageto protect the origin. It also binds browsers. Fix: protect the origin withs-maxage/CDN-Cache-Control, keepmax-agesmall. stale-while-revalidateshorter than revalidation latency. The stale window closes mid-refresh and requests block. Fix: size SWR to several multiples ofs-maxage.- No
stale-if-error. A brief origin blip becomes a site-wide error. Fix: add a boundedstale-if-errorwindow. - Forgetting
immutableon hashed assets. Browsers issue needless revalidations. Fix: addimmutableso they never revalidate within the window.
Production deployment checklist
- CDN window (
s-maxage - Browser window (
max-age) capped at tolerable staleness;0 -
CDN-Cache-ControlandCache-Control -
stale-while-revalidatesized to several multiples of -
stale-if-error - Hashed assets carry
Frequently Asked Questions
Should max-age and s-maxage be the same value?
Usually not. s-maxage controls the purgeable CDN cache and can be long for origin protection, while max-age controls the unpurgeable browser cache and must stay within your tolerable-staleness window. Splitting them — typically a long s-maxage and a max-age of 0 for dynamic content — is the whole point of tuning.
How long can s-maxage safely be?
As long as you can purge on real changes. Because the CDN is purgeable in seconds, s-maxage can far exceed your tolerable-staleness number when paired with tag-based invalidation. Without on-demand purging, cap s-maxage at the tolerable-staleness number instead.
What value should I pick for stale-while-revalidate?
Make it long enough to cover the gap until the next natural request and any origin latency spike. A practical starting point is roughly five times s-maxage, then tune from hit-ratio and revalidation-latency data. Too short forces blocking fetches; too long lets slow content drift go unnoticed.
Why does my browser keep serving an old response after I purged the CDN?
Because the browser cache obeys max-age, and a purge only clears the CDN. If max-age is long, the client holds the old copy until it expires, regardless of CDN state. Keep max-age small (or 0, must-revalidate) for any content you might need to correct quickly.