A cache policy needs a behavioral test, not just a header-presence check. Two responses can both contain max-age yet behave differently because one also requires validation or prohibits storage. The request matters too: a browser can bypass an otherwise reusable copy without changing the origin’s response policy or deleting an older cached entry.

Both sides must agree

Cache-Control directives are directional. A request directive is not automatically copied into the response, and RFC 9111 describes request directives as advisory. Response directives impose requirements on conforming caches. Intermediaries must forward applicable cache directives even when they do not implement a cache themselves. Deployment-specific overrides need inspection; they are not evidence that the origin’s declared policy was followed.

Separate three questions. May this response be stored? Is its current age within the applicable freshness lifetime? Does this request permit reuse without validation? No-store prohibits HTTP cache storage; no-cache permits storage but requires successful validation before reuse. Max-age sets freshness, while must-revalidate prohibits unvalidated reuse after the response becomes stale. Must-revalidate does not require a round trip while the response is still fresh.

Private restricts storage to single-user caches; public explicitly permits caching subject to other requirements and is not mandatory on every cacheable response. S-maxage overrides freshness for shared caches, not browser caches. Vary supplies request-matching dimensions. None of these mechanisms identifies the current application account or makes personal data safe merely because its lifetime is short.

When it breaks

Testing with a new query parameter on every request changes the cache key and can hide reuse entirely. Disabling the browser cache in developer tools can create the same misleading result. Some automation interception mechanisms also disable caching. A useful test repeats the same URL in one isolated context and counts requests at the origin, while asserting the returned body as well.

Fetch’s cache option is a browser API, not a response header. Its no-store mode bypasses lookup and avoids updating the HTTP cache; reload bypasses lookup but can update it. Neither should be confused with erasing all previous copies. Likewise, changing origin headers cannot force an already-fresh entry to contact the origin immediately. History restoration and service-worker storage introduce additional behavior outside this test.

No-cache does not promise a 304. A validator and an unchanged representation are needed for that optimization. Without ETag or Last-Modified, repeated requests can transfer complete 200 responses. Count both origin visits and transferred bodies before concluding that validation is saving work.

Check it quickly

This was tested on macOS with Node 26.0.0, Playwright 1.63.0, and its Chromium 153.0.8010.12 headless shell. Install into a temporary directory; Linux also needs Playwright’s documented browser system dependencies. The download needs network access. Run the JavaScript block using NODE_PATH="$cache_browser_lab/node_modules" PLAYWRIGHT_BROWSERS_PATH="$cache_browser_lab/browsers" node. Test requests use only an automatically allocated loopback port, with no existing browser profile.

cache_browser_lab=$(mktemp -d)
npm install --prefix "$cache_browser_lab" --ignore-scripts --no-audit --no-fund --package-lock=false playwright@1.63.0
PLAYWRIGHT_BROWSERS_PATH="$cache_browser_lab/browsers" node "$cache_browser_lab/node_modules/playwright/cli.js" install chromium --only-shell --no-remove
const assert = require("node:assert/strict");
const http = require("node:http");
const { once } = require("node:events");
const { chromium } = require("playwright");

const policies = new Map([
  ["/fresh", "max-age=3600"],
  ["/must", "max-age=3600, must-revalidate"],
  ["/validate", "no-cache, max-age=3600"],
  ["/discard", "no-store, max-age=3600"],
]);
const hits = new Map();
const server = http.createServer((request, response) => {
  const route = new URL(request.url, "http://127.0.0.1").pathname;
  if (route === "/") {
    response.writeHead(200, { "Content-Type": "text/html", "Cache-Control": "no-store" });
    response.end("<!doctype html><title>Cache experiment</title>");
  } else if (policies.has(route)) {
    const count = (hits.get(route) || 0) + 1;
    hits.set(route, count);
    response.writeHead(200, {
      "Content-Type": "text/plain", "Cache-Control": policies.get(route),
    });
    response.end(`${route}:${count}`);
  } else {
    response.writeHead(404, { "Cache-Control": "no-store" });
    response.end();
  }
});

async function main() {
  server.listen(0, "127.0.0.1");
  await once(server, "listening");
  let browser;
  try {
    browser = await chromium.launch();
    console.log("Chromium", browser.version());
    const context = await browser.newContext({ serviceWorkers: "block" });
    try {
      const page = await context.newPage();
      await page.goto(`http://127.0.0.1:${server.address().port}/`);
      const read = (route, cache = "default") => page.evaluate(async ({ route, cache }) => {
        const response = await fetch(route, { cache });
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        return response.text();
      }, { route, cache });
      for (const [route, expectedHits] of [
        ["/fresh", 1], ["/must", 1], ["/validate", 2], ["/discard", 2],
      ]) {
        const first = await read(route);
        const second = await read(route);
        assert.equal(first, `${route}:1`);
        assert.equal(second, `${route}:${expectedHits}`);
        assert.equal(hits.get(route), expectedHits);
        console.log(route, "origin requests:", hits.get(route));
      }
      assert.equal(await read("/fresh", "no-store"), "/fresh:2");
      assert.equal(await read("/fresh"), "/fresh:1");
      assert.equal(hits.get("/fresh"), 2);
      assert.equal(await read("/fresh", "reload"), "/fresh:3");
      assert.equal(await read("/fresh"), "/fresh:3");
      assert.equal(hits.get("/fresh"), 3);
      console.log("no-store bypass retained version 1; reload stored version 3");
    } finally {
      await context.close();
    }
  } finally {
    if (browser) await browser.close();
    await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
  }
}

main().catch(error => { console.error(error); process.exitCode = 1; });

The initial origin counts are one, one, two, and two. Both fresh policies reuse the first body. The no-cache and no-store responses reach the origin again despite their large max-age. This fixture intentionally has no validators, so those visits return full bodies; it does not distinguish their storage eligibility by counts alone.

The later no-store fetch obtains version two, but an ordinary fetch still returns the previously cached version one. Reload obtains and stores version three, which the next ordinary fetch reuses. Origin counters ensure these are actual cache decisions, not coincidentally identical response strings. The server and isolated browser context are closed afterward.

These observations cover this Chromium HTTP cache and these exact requests. They do not verify a shared CDN, persistence across browser restarts, stale delivery, or user-account isolation. Keep those separate acceptance tests rather than extending a successful local cache experiment into a broader claim.

Sources