How to check stale-while-revalidate
Read the directive with curl -s -D - -o /dev/null <url> | grep -i cache-control and look for stale-while-revalidate=<seconds> beside max-age. curl keeps no cache, so the header is all it can show. Proving the window is used takes a cache in front, where age above max-age on a 200 is the signature.
Why check this
stale-while-revalidate changes what a user sees for a measurable stretch of time, so it belongs in the test plan for any release that touches cache rules. Inside the window a visitor is served content the cache already knows is out of date, and the refresh happens on a later request. A price change or a feature flag published with a 30 second window reaches some visitors 30 seconds late, and the tester who files "the page still shows the old value" is reporting the configured behaviour. Checking it means knowing the window and confirming who honours it.
Prerequisites
- curl 7.0 or later. Any build reads headers. See the curl manual.
- Node 18 or later for the two servers below: an origin on port 8471 that sends the directive and numbers each response, and a cache on port 8472 that honours the window. The origin counter makes the stale answer visible.
- RFC 5861 section 3 defines the extension, and the MDN Cache-Control reference lists browser support.
Save this as swr-demo.js and start it with node swr-demo.js. Stop it with Ctrl+C when the check is done.
const http = require('node:http');
let version = 0;
http.createServer((req, res) => {
version += 1;
res.writeHead(200, { 'cache-control': 'public, max-age=2, stale-while-revalidate=30' });
res.end('version ' + version + '\n');
}).listen(8471);
let entry = null;
const load = async () => {
const r = await fetch('http://127.0.0.1:8471/');
entry = { at: Date.now(), body: await r.text(), cc: r.headers.get('cache-control') };
return entry;
};
http.createServer(async (req, res) => {
const send = (e, state) => {
res.writeHead(200, { age: String(Math.floor((Date.now() - e.at) / 1000)), 'cache-control': e.cc, 'x-demo-cache': state });
res.end(e.body);
};
if (!entry) return send(await load(), 'MISS');
const age = (Date.now() - entry.at) / 1000;
const maxAge = Number(/max-age=(\d+)/.exec(entry.cc)?.[1] ?? 0);
const swr = Number(/stale-while-revalidate=(\d+)/.exec(entry.cc)?.[1] ?? 0);
if (age <= maxAge) return send(entry, 'HIT');
if (age <= maxAge + swr) { const stale = entry; void load(); return send(stale, 'STALE'); }
return send(await load(), 'EXPIRED');
}).listen(8472, () => console.log('origin on 8471, cache on 8472'));
Steps
- Step 1.
Read the directive from the origin. This is the same command you run against the service under test.
curl -s -D - -o /dev/null http://127.0.0.1:8471/ | grep -i '^cache-control'cache-control: public, max-age=2, stale-while-revalidate=30Two numbers, two windows. The response is fresh for 2 seconds, then servable while stale for another 30.
- Step 2.
Send the first request through the cache. Nothing is stored yet, so it fetches and stores.
curl -s -D - http://127.0.0.1:8472/ | grep -i -E '^age|^x-demo-cache|^version'age: 0 x-demo-cache: MISS version 2The body reads
version 2because step 1 already spent request one on the origin. Note the number; it is how you will recognise a stale answer. - Step 3.
Wait past
max-ageand request again while still inside the 30 second window.sleep 4 && curl -s -D - http://127.0.0.1:8472/ | grep -i -E '^age|^x-demo-cache|^version'age: 4 x-demo-cache: STALE version 2age: 4againstmax-age=2is the whole signature: the entry expired two seconds ago and the cache answered anyway, without waiting for the origin. The body is still the old version. - Step 4.
Request once more. The background refresh started during step 3 has finished by now.
sleep 1 && curl -s -D - http://127.0.0.1:8472/ | grep -i -E '^age|^x-demo-cache|^version'age: 1 x-demo-cache: HIT version 3The version number moved and
agereset. No request ever waited on the origin, which is what the extension buys. - Step 5.
Run step 1 against the real service and read what comes back. Most responses carry no such directive.
curl -s -D - -o /dev/null 'https://www.cloudflare.com/' | grep -i '^cache-control'cache-control: max-age=10One number only. There is no stale window here, so every request after 10 seconds waits for a revalidation.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| max-age=2, stale-while-revalidate=30 | Fresh 2 s, then stale-servable 32 s from storage | Confirm the product accepts content up to 32 seconds old on that route. |
| age above max-age with a 200 | Something in front is serving stale on purpose | Match the excess against the window. Beyond it, the cache is ignoring the directive. |
| The directive is present and age never passes max-age | Traffic is too sparse to reach the window | Drive the requests yourself, as steps 2 to 4 do, rather than waiting on live traffic. |
| Only max-age, as in step 5 | No stale window is configured | Every expiry costs a revalidation. Add the directive or accept the latency. |
| The body changes only on the second request after an edit | The first request served stale and triggered the refresh | Working as configured. Publish checks must allow for one extra request. |
Thresholds
Common mistakes
What to check next
- How to check cache-control header: the directive list this extension is written into.
- How to check if CDN cache is hit or miss: the edge status header that names a stale answer directly.
- How to check ETag header: the background revalidation is a conditional request, and a validator makes it cheap.
- How to check last-modified header: the date-based validator used when no ETag is sent.
FAQ
How to check cache headers with curl?
-D - prints the response headers, -o /dev/null drops the body, and grep -i keeps the line. Step 1 is the whole read. curl never caches, so it reports the contract and never the behaviour.
Does stale-while-revalidate work without max-age?
It needs a freshness lifetime to be stale relative to. With max-age absent the cache falls back to a heuristic, and the start of the window becomes unpredictable. Set both numbers on the same header.
How long does a visitor see stale content?
Up to stale-while-revalidate seconds after max-age expires, and only until a refresh completes. In the demo that is a 30 second ceiling on top of a 2 second lifetime.
How is it different from stale-if-error?
stale-while-revalidate serves stale while a refresh runs normally. stale-if-error serves stale only when the revalidation fails. They cover different situations and can be set together.
Verified
Verified by Maks Vernycurl 8.21.0node 22.23.2
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate8 minpublished updated Maks Verny