Most API teams reach for a Redis cache or a CDN long before they've spent the one HTTP header that does the job for free. Cache-Control lets clients, proxies, and CDNs cache your responses correctly without you running any infrastructure. Get it right and a huge slice of your traffic never touches your origin. Get it wrong and you either serve stale data or melt your database. Here's how to think about it.
The header that does the work
Cache-Control is a response header that tells every cache in the chain how long a response stays fresh and who is allowed to store it.
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: public, max-age=300
{"id": 42, "name": "Widget", "price": 1999}
max-age=300 means the response is fresh for 300 seconds. During that window, a browser or CDN serves its stored copy without asking your server at all. public means shared caches (CDNs, proxies) may store it; use private for per-user data so a CDN never serves one user's response to another.
For data that must never be cached:
Cache-Control: no-store
And for data you can cache but must revalidate every time before using:
Cache-Control: no-cache, max-age=0
no-cache is the most misunderstood directive — it does not mean "don't cache." It means "store it, but check with the origin before serving." That check is cheap when you pair it with an ETag.
Setting it from your code
In Express, set freshness per route based on how volatile the data is:
app.get("/products/:id", async (req, res) => {
const product = await db.products.find(req.params.id);
// Product catalog changes rarely — cache for 5 min in shared caches
res.set("Cache-Control", "public, max-age=300");
res.json(product);
});
app.get("/me", async (req, res) => {
const user = await getUser(req);
// Personal data — only the user's own browser may cache it
res.set("Cache-Control", "private, max-age=30");
res.json(user);
});
The rule of thumb: pick a max-age you'd be comfortable serving stale for. A currency rate that updates hourly can use max-age=3600. A live inventory count probably wants max-age=10 or no-cache.
The directive that changes everything: stale-while-revalidate
The hard part of caching is the moment a cached entry expires. Naively, the next request blocks while you regenerate the response — so every cache expiry produces one slow request, and under load a "cache stampede" can hit your origin with thousands of simultaneous regenerations.
stale-while-revalidate fixes this:
Cache-Control: public, max-age=60, stale-while-revalidate=300
This says: serve from cache for 60 seconds. After that, for up to 300 more seconds, keep serving the stale copy instantly while refreshing it in the background. The user never waits. Your origin gets exactly one revalidation request instead of a thundering herd.
You can test the behavior with a quick fetch loop:
async function poll(url, times = 5) {
for (let i = 0; i < times; i++) {
const res = await fetch(url);
console.log(res.status, res.headers.get("age"), res.headers.get("x-cache"));
await new Promise(r => setTimeout(r, 1000));
}
}
poll("https://api.example.com/products/42");
Watch the age header climb while the response is served from cache, then reset after a background revalidation — all without a single slow request on the client side.
A safe default to copy
If you only remember one line, make read-only, non-personalized endpoints return:
Cache-Control: public, max-age=60, stale-while-revalidate=600
Short freshness so data isn't badly out of date, a long SWR window so a cold cache never causes a latency spike, and public so your CDN absorbs the load. Pair it with an ETag and conditional requests for the cases where the data really hasn't changed and you want the cache to confirm cheaply.
Knowing what your API actually sends
The tricky part of caching is that the right header depends on the endpoint, and it's easy to ship no-store everywhere "to be safe" and quietly throw away your performance. If you want to inspect, document, and test caching headers across every endpoint in one place — and share that with your team — APIKumo lets you organize your API collections, run requests, and see exactly which Cache-Control directives each route returns, so your caching strategy is something you can verify instead of guess at.
Top comments (0)