HTTP Caching

An HTTP cache keeps a copy of a response so that a later request for the same resource can be answered without a full trip to the origin server, the server that owns the resource. Your browser has a private cache; shared caches such as a proxy or a CDN sit between many users and the server. RFC 9111 defines the rules, and the server sets them with response headers.

Example: a product page calls GET /api/products/1042, and the response carries Cache-Control: max-age=60 and ETag: "p1042-v7". For 60 seconds the browser reuses its copy without asking. After that it asks whether p1042-v7 is still current; if it is, the server answers 304 Not Modified with no body.

Why it matters

Caching makes repeat visits fast and takes load off the servers, but every cached copy can be out of date or reach the wrong person. Typical defects: an old price or an old script after a release, a page in the wrong language, and the worst kind, a shared cache that serves one customer's account page to the next customer.

Test environments often lack a CDN, and testers often work with DevTools' "Disable cache" switched on, so these defects surface in production unless someone reads the caching headers before release.

Fresh or stale: what Cache-Control decides

A cache stores a response under a cache key made of at least the request method and the URL; most stored responses are successful GET responses. While a stored response is fresh, the cache may reuse it without contacting the server. Once it is stale, the cache normally has to check with the server first.

The freshness lifetime comes from max-age, or else from the older Expires header, which holds a fixed date and is ignored when max-age is present. With neither, a cache may guess a lifetime (heuristic freshness), often from Last-Modified. So a response without caching headers is not "uncached", only unpredictable.

  • max-age=600: fresh for 600 seconds. s-maxage sets the lifetime for shared caches only.
  • no-cache: may be stored, but must be revalidated with the server before every reuse. Despite its name, it does not forbid caching.
  • no-store: no cache may store the request or the response. This is the directive for personal and sensitive data.
  • private: only the user's own (private) cache may store it. public: any cache may, even where it otherwise would not.
  • must-revalidate: once stale, the copy must not be used without a successful check, even if the server is unreachable.
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: max-age=60
ETag: "p1042-v7"
Last-Modified: Wed, 30 Sep 2026 08:00:00 GMT
Date: Thu, 01 Oct 2026 09:00:00 GMT

Try it: Cache Header Analyzer Try it: HTTP Header Builder

Revalidation: ETag, Last-Modified and 304

To check a stale copy cheaply, the server labels each response with a validator: ETag, an opaque version tag such as "p1042-v7", or Last-Modified, a date. The cache sends it back in a conditional request: If-None-Match for the ETag, If-Modified-Since for the date. When both are present, RFC 9110 tells the server to ignore If-Modified-Since.

If nothing changed, the server answers 304 Not Modified: no body, only headers such as ETag, Cache-Control and Date, which refresh the stored copy that the client then shows. If the resource changed, it answers 200 OK with the new body and a new ETag. An ETag starting with W/ is weak: it promises equivalent content, not byte-for-byte identical content.

A stale copy is revalidated with If-None-Match: if the ETag still matches, the server answers 304 Not Modified with no body; if the product changed, it sends 200 OK with the new body and a new ETag.
  • Product unchanged

    • Browser private cache
    • Origin server shop.example
    1. 1 From Browser To Origin server: First request GET /api/products/1042
    2. 2 From Origin server To Browser: 200 OK with the body and a validator reply Cache-Control: max-age=60 ETag: "p1042-v7"
    3. 3 Browser internally: Reuses the stored copy without asking Fresh for 60 seconds: no request is sent
    4. 4 From Browser To Origin server: Copy is stale: conditional request If-None-Match: "p1042-v7"
    5. 5 Origin server internally: Compares the ETag: still current
    6. 6 From Origin server To Browser: 304 Not Modified, no body reply New Date and Cache-Control refresh the stored copy Pass

    Outcome: The browser shows the body it already has, fresh for another 60 seconds.

  • Product changed

    • Browser private cache
    • Origin server shop.example
    1. 1 From Browser To Origin server: Copy is stale: conditional request If-None-Match: "p1042-v7"
    2. 2 Origin server internally: Compares the ETag: the price changed The current version is p1042-v8
    3. 3 From Origin server To Browser: 200 OK with the new body reply ETag: "p1042-v8"
    4. 4 Browser internally: Replaces the stored copy

    Outcome: The new price is shown, and the next conditional request carries "p1042-v8".

GET /api/products/1042 HTTP/1.1
Host: shop.example
If-None-Match: "p1042-v7"

HTTP/1.1 304 Not Modified
Cache-Control: max-age=60
ETag: "p1042-v7"
Date: Thu, 01 Oct 2026 09:01:05 GMT

Try it: cURL Generator Try it: HTTP Headers Parser

Shared and private caches

A private cache serves one user, normally the browser. A shared cache stores responses for many users: a proxy, a CDN or a reverse proxy in front of the application. RFC 9111 protects one case by default: a shared cache must not reuse a response to a request that carried an Authorization header unless the response allows it, for example with public or s-maxage.

A session cookie gets no such protection, so a personalised response must itself say private or no-store. In the lesson on authentication and authorization you checked who may see which data; a shared cache is one more way for the wrong person to see it.

  • Vary names the request headers the response depends on, such as Vary: Accept-Language. Without it, a shared cache can serve the Arabic page to someone who asked for English.
  • A successful POST, PUT or DELETE invalidates the stored copy of that URL only in the caches the request passes through; other caches keep theirs until it goes stale or is purged.
  • An Age header means the response came from a cache. Many CDNs also add their own header that reports a hit or a miss; its name depends on the vendor.

Worked example: testing a product page behind a CDN

A store serves its product pages through a CDN. Rules: a new price must show within one minute, and account pages must never be cached. Turn the rules into expected headers for each type of response, then test the behaviour with repeatable requests (curl, the API tools or the DevTools Network panel), noting how each page was loaded: link, reload or hard reload.

  • Versioned file such as app.3f9c1a.js: a long max-age is correct, because every release gets a new file name.
  • Product API with max-age=60 and an ETag: repeat the request with If-None-Match and expect 304 with no body. Change the price, wait 60 seconds, repeat: expect 200, the new price and a new ETag.
  • Through the CDN: send the same request twice and check that Age appears and grows. Then send it with Accept-Language: ar and with en and compare the bodies.
  • Account pages: expect no-store. Open the same orders URL as customer A and as customer B in two browsers; B must never see A's orders. After logout, Back must not show private data; test this in each target browser, because RFC 9111 does not require the browser history to follow caching rules.

Try it: API Header Validator

Common mistakes

  • Reading no-cache as "do not cache"; it means "revalidate before reuse", and no-store is what forbids storing.
  • Assuming a response without caching headers is never cached; caches may guess a lifetime.
  • Testing only with "Disable cache" switched on, or only where there is no CDN, and concluding that caching works.
  • Checking that a 304 arrives but not that the page then shows current content.
  • Testing personalised pages with a single account; leaks between users need two.
  • Forgetting Vary when the response depends on the language or another request header.

The QA perspective

Caching is configured in code, in the web server and in the CDN, often by different people. During refinement, ask how fresh each kind of data must be and record testable answers, such as "prices: at most 60 seconds old; account pages: never stored". These are your expected results.

In a caching bug report, include the full request and response headers, which cache answered (with its Age or hit-or-miss header), the time of each request and how the page was loaded; otherwise a developer who reloads with an empty cache will not reproduce it. Retest caching after every CDN configuration change.

Practice exercise

Review the caching headers of an online store

Your task

The five exchanges below come from a store behind a CDN. The rules: prices may be at most 60 seconds old, no cache may store account data, and product data comes in the language requested with Accept-Language. Response 2 is the product page that shows the price; in exchange 5 the product has not changed.

Decide whether each response is cached correctly, and describe every problem you find.

Material
1) GET /assets/app.3f9c1a.js
   -> 200 OK
      Cache-Control: public, max-age=31536000

2) GET /products/1042
   -> 200 OK
      Content-Type: text/html
      Cache-Control: public, max-age=86400

3) GET /api/account/orders
   Authorization: Bearer <access token>
   -> 200 OK
      Cache-Control: public, max-age=300

4) GET /api/products/1042
   Accept-Language: ar
   -> 200 OK
      Content-Language: ar
      Cache-Control: max-age=60
      ETag: "p1042-v7"
      Vary: Accept-Encoding

5) GET /api/products/1042
   If-None-Match: "p1042-v7"
   -> 200 OK
      ETag: "p1042-v7"
      Content-Length: 5120
Steps
  1. For each response, write who may store it (browser, shared caches, nobody) and for how long.
  2. Compare that with the content and the rules.
  3. For each problem, write the risk, the headers you expect and one test that goes through the real caches.
Show the model answer
Model answer

Response 1 is correct: the version is part of the file name, so a release changes the URL and a one-year max-age cannot serve old code.

Response 2 is a defect: any cache may show a price up to 24 hours old. Expected: max-age=60, or no-cache with an ETag. Test: change a price and open the page through the CDN 61 seconds later.

Response 3 is a high-severity security defect: public lets a shared cache reuse a response to a request with Authorization, so the CDN may show one customer's orders to another. Expected: no-store. Test: two accounts, the same URL, through the CDN; each must see only their own orders.

Response 4 is a defect: the body depends on Accept-Language, which Vary does not list, so the CDN can serve the Arabic body to an English request. Expected: Vary: Accept-Encoding, Accept-Language. Test: request in Arabic, then in English, and compare Content-Language.

Response 5 is a defect: the ETag matches, so the answer should be 304 Not Modified with no body. Resending 5,120 bytes wastes bandwidth without showing wrong data, so it is less severe than 3. The response also lacks Cache-Control, so caches may guess its lifetime. Expected: 304 with the ETag, Cache-Control: max-age=60 and the same Vary as the fixed response 4. Test: repeat the request and expect 304.

Frequently asked questions

What is the difference between no-cache and no-store?

no-cache lets a cache store the response but requires it to revalidate with the server before every reuse, so the user always gets current content, often through a cheap 304. no-store forbids storing the request and the response at all. Use no-store for personal or sensitive data.

Should a server send ETag or Last-Modified?

Both work as validators and servers often send both, but ETag is more precise: Last-Modified has a resolution of one second and can change when a file is saved again with the same content. When a request carries both conditions, the server uses If-None-Match and ignores If-Modified-Since.

How do I test caching when the test environment has no CDN?

Check the headers there, because the application sets them. Test shared-cache behaviour (Age, hit or miss, Vary, leaks between users) in an environment with the same CDN or proxy configuration as production, such as staging. If there is none, record the gap in your test plan.

Why does the browser send no request at all for some files?

Because they are fresh: within max-age the browser reuses its copy without contacting the server, and the Network panel shows them as served from the cache. A conditional request is sent when the copy is stale or marked no-cache, and a reload can also trigger one, depending on the browser.

References

  • RFC 9111: HTTP CachingIETF · 2022How Cache-Control, Expires, ETag and validators decide whether a response may be stored and reused.
  • RFC 9110: HTTP SemanticsIETF · 2022The meaning of HTTP methods, header fields, status codes and content negotiation, independent of the wire version.
  • HTTP caching — MDNMDN Web Docs (Mozilla)Private vs. shared caches, freshness, validation with ETag / Last-Modified and the common Cache-Control patterns.
  • OWASP Cheat Sheet Series: Session ManagementOWASP FoundationSession identifier entropy, cookie attributes, expiry, renewal after login and logout handling.

Practise with Mutqan tools

Analyze Cache-Control, Expires, ETag, Last-Modified, Vary and Age headers and explain how browsers and CDNs will cache the response.

Validation

Parse raw HTTP request/response headers into a structured table with explanations.

Web

Compose HTTP headers with presets for caching, CORS, security and content negotiation.

Web

Validate API response headers for correctness, security and caching best practices.

API Testing

Build cURL commands from method, URL, headers, body and auth options.

Web

Go deeper

Your progress on this topic

Part of the QA Roadmap