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

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

  1. 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=30

    Two numbers, two windows. The response is fresh for 2 seconds, then servable while stale for another 30.

  2. 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 2

    The body reads version 2 because step 1 already spent request one on the origin. Note the number; it is how you will recognise a stale answer.

  3. Step 3.

    Wait past max-age and 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 2

    age: 4 against max-age=2 is 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.

  4. 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 3

    The version number moved and age reset. No request ever waited on the origin, which is what the extension buys.

  5. 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=10

    One 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

The servable lifetime is max-age plus stale-while-revalidate. With max-age=2 and stale-while-revalidate=30 a cache may answer from storage for 32 seconds, and step 3 shows it answering at age 4. Source: RFC 5861 section 3, confirmed against the local cache on 2026-09-11, see the Verified block

Common mistakes

Sign: curl shows the directive, so the team records the check as passed.Cause: curl stores nothing and revalidates nothing, so every curl request is a cold one. The header proves the origin asks for the behaviour. Only a cache that implements RFC 5861 proves anyone provides it.
Sign: A content change is published and the old value is still served, then it corrects itself.Cause: The first request inside the window is answered from the stale copy while the refresh runs behind it. This is the configured behaviour, not a bug. Any publish test needs a second request before it asserts.
Sign: The directive is set and no visitor ever sees a stale response.Cause: The stale copy has to already be in the cache. On a low traffic route the entry is evicted before the window matters, so every request is a miss and the extension never applies.
Sign: Browser and CDN disagree about how long stale content is served.Cause: The extension is honoured by different layers to different degrees, and a CDN may apply its own stale rules on top. Read age from the response that reaches the client, not from the origin configuration.

What to check next

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.

intermediate8 minpublished updated Maks Verny