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.

Browser cache window versus CDN cache window max-age controls a short browser window because browsers cannot be purged, while s-maxage controls a long CDN window because the CDN can be purged in seconds. Response Cache-Control header Browser cache max-age = short cannot be purged CDN edge cache s-maxage = long purgeable in seconds Origin shielded by CDN
Give the unpurgeable browser cache a short window and the purgeable CDN cache a long one; s-maxage is what lets a single response do both.

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-age on dynamic content. Browsers cache the bug with no purge path. Fix: max-age=0, must-revalidate for dynamic, long windows only for hashed assets.
  • Relying on max-age to protect the origin. It also binds browsers. Fix: protect the origin with s-maxage/CDN-Cache-Control, keep max-age small.
  • stale-while-revalidate shorter than revalidation latency. The stale window closes mid-refresh and requests block. Fix: size SWR to several multiples of s-maxage.
  • No stale-if-error. A brief origin blip becomes a site-wide error. Fix: add a bounded stale-if-error window.
  • Forgetting immutable on hashed assets. Browsers issue needless revalidations. Fix: add immutable so they never revalidate within the window.

Production deployment checklist

  • CDN window (s-maxage
  • Browser window (max-age) capped at tolerable staleness; 0
  • CDN-Cache-Control and Cache-Control
  • stale-while-revalidate sized 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.