Understanding Browser Caching with Cache‑Control and Validators

Hero image for Understanding Browser Caching with Cache‑Control and Validators. Image by Mario Gogh.
Hero image for 'Understanding Browser Caching with Cache‑Control and Validators.' Image by Mario Gogh.

In Brief

A fresh cached response can be reused without contacting the server. A stale response may still be reused after validation with an ETag or Last-Modified value. Cache-Control sets the storage and freshness policy, whilst Vary separates representations selected by request headers. A 304 Not Modified saves the response body, but it still requires a request. Private and shared caches need different policies, especially for personalised responses.

Reload a page with the Network panel open and the result can look oddly inconsistent. One stylesheet is absent from the second request, another returns 304 Not Modified, and an API response comes back as a full 200 OK. They may all be behaving correctly. The browser is making a different caching decision for each response.


Freshness and Validation are Separate Decisions

A cache exists to avoid doing work twice. It stores a response and, when another request arrives, decides whether that stored response can satisfy it.

There are two useful outcomes. If the response is still fresh, the cache can usually use it without asking the origin server anything. If it is stale, the cache may send a conditional request to find out whether the stored response is still valid. That second route costs a network round trip, but can avoid transferring the response body again.

This distinction matters because a validator does not make a response fresh. Adding an ETag without an appropriate cache policy merely gives the browser something to check later. Likewise, a long freshness lifetime without a safe invalidation plan can leave users looking at yesterday's bytes.


Start with an Explicit Cache Policy

Cache-Control describes where a response may be stored and when it becomes stale. The common directives answer different questions:

  • max-age=600 says the response becomes stale after 600 seconds.
  • public allows a shared cache to store a response that might not otherwise be stored there.
  • private limits storage to a private cache, such as a browser cache for one user.
  • no-cache permits storage, but requires successful validation before the response is reused.
  • no-store tells private and shared caches not to store the response.
  • s-maxage=600 sets the freshness lifetime for shared caches, overriding max-age there.

The naming of no-cache is unfortunate. It does not mean "do not cache". The response directive in RFC 7234 requires validation before reuse. no-store is the directive that prevents intentional storage.

For a versioned public asset, a response might begin like this:

HTTP/1.1 200 OKContent-Type: text/cssCache-Control: public, max-age=600ETag: "site-css-v4"Last-Modified: Wed, 15 Nov 2017 09:30:00 GMTVary: Accept-Encodingbody { margin: 0; }

For ten minutes, a cache can treat that response as fresh. The validators are stored with it, but they are not needed during that fresh period.


Validators Give Stale Responses Another Chance

An ETag is an opaque identifier chosen by the server for a representation. It may be based on a file version, a content hash, or another repeatable revision value. The client does not need to understand it. It only needs to return the value exactly.

Last-Modified is a date supplied by the server. It works well when the resource has a dependable modification time and onesecond precision is sufficient. It is a poor fit when the server invents the current time on every request or when several servers disagree about the date.

Once the stored stylesheet is stale, the browser can send its entity tag in If-None-Match:

GET /css/site.css HTTP/1.1Host: example.comIf-None-Match: "site-css-v4"

If that representation has not changed, the server replies without another copy of the CSS:

HTTP/1.1 304 Not ModifiedCache-Control: public, max-age=600ETag: "site-css-v4"Vary: Accept-Encoding

The cache keeps its stored body, updates the relevant metadata, and can treat the response as fresh again. If the entity tag no longer matches, the server returns the new representation in a normal 200 OK response.

The datebased exchange follows the same pattern. The cache sends If-Modified-Since with the stored Last-Modified value. If the representation has not changed since that date, the response can be 304 Not Modified.

When a request contains both conditions, If-None-Match takes precedence. RFC 7232 requires If-Modified-Since to be ignored in that case, because the entity tag is the more accurate validator.


Vary Keeps Representations Apart

A cache first identifies stored responses with a primary key made from the request method and target URI. In practice, most stored responses are for GET. That primary key is not always enough. A server may return compressed content when Accept-Encoding includes gzip, a translated document according to Accept-Language, or another representation selected by a request header.

Vary: Accept-Encoding tells a cache that the value of that request header forms part of its selection. A compressed response must not be handed to a request that selected a different representation. The validator must describe the selected representation too, otherwise a matching tag can validate the wrong bytes.

Vary is easy to omit because the application code often sees only its own response. A reverse proxy may compress the body later. Inspect the response that actually reaches the browser, not merely the headers the application intended to send.


Private and Shared Caches Need Different Answers

A browser cache is normally private to one user. A proxy cache can be shared by many users. That makes a caching mistake on a personalised response more serious than a stale logo.

Use private when a response belongs to one user but may still be kept by that user's browser. RFC 7234 restricts shared caching of a response to a request containing Authorization unless the response explicitly permits it. That safeguard is not permission to mark personalised content public simply to improve a performance chart. Use no-store for material that should not be stored, whilst remembering that the directive is not a substitute for transport security or sound application security.

The right policy comes from the response, not from a sitewide header snippet. Public assets, anonymous documents, personalised pages, and sensitive account responses rarely deserve the same directives.


Debug the Exchange, Not the Reload Button

The Network panel shows whether a request went to the network and which headers travelled in each direction. A response reported as coming from memory or disk cache is different from a 304: the former may involve no network request, whilst the latter proves that validation took place somewhere.

Keep the cache enabled whilst investigating cache behaviour. "Disable cache" is useful for other work, but it changes the mechanism being tested. Then inspect the sequence:

  1. Confirm the first response is actually cacheable.
  2. Record Cache-Control, ETag, Last-Modified, and Vary.
  3. Repeat the request before and after the freshness lifetime expires.
  4. Check for If-None-Match or If-Modified-Since on the conditional request.
  5. Confirm that a matching validator produces a bodyless 304, and a changed validator produces a full 200.
  6. Check whether a proxy has altered compression, Vary, or the validator.

curl is useful for removing browser reload behaviour from the test:

curl --include https://example.com/css/site.csscurl --include \  --header 'If-None-Match: "site-css-v4"' \  https://example.com/css/site.css

Use the exact URL and request headers for both calls. Changing the selected representation whilst testing the validator only creates a more interesting problem.

Caching is one part of a wider performance picture. The later overview of website performance with HTML, CSS, and JavaScript puts it alongside asset size and delivery, frontend performance budgets turn those concerns into limits, and the explanation of Time to First Byte covers the server delay that still matters when a request reaches the origin.


Wrapping Up

Browser caching becomes easier to reason about once freshness and validation stop being treated as the same thing. Cache-Control decides whether a stored response may be reused without asking. ETag and Last-Modified let the cache ask whether stale bytes still stand. Vary makes sure it asks about the correct representation.

The useful test is not whether a header exists. It is whether the complete exchange keeps the right response, for the right user, for the intended length of time.


Postscript

Aug 2026: This article forms part of an archive restored from a previous version of my website. Its original publication date is accurate. During the restoration, I reviewed and updated it where appropriate for formatting, imagery, broken links, code correctness, and current internal references, whilst preserving the original technical context and intent.

The HTTP caching model described here remains useful, although RFC 9111 has since replaced RFC 7234 and modern frameworks now add separate application and datacache layers. Next.js cache tags and revalidation are one such layer; they do not change the distinction between HTTP freshness and validation.


Have a complex web platform issue?

Tell me what is blocked, what has changed, and what needs to be true after the fix. I'll come back with a practical next step.