HTTP caching headers: Cache-Control, ETag and more

Every header that controls caching, in plain words: Cache-Control, ETag, Last-Modified, Vary and Age. What each one does, how to set it, and how to test it.

IntermediateExplainer13 min readUpdated October 30, 2026

In this guide
  1. Where these headers sit
  2. Cache-Control, directive by directive
    1. The pairs people mix up
    2. When directives clash
    3. Which browsers understand the newer directives
  3. Validation: ETag, Last-Modified and the 304
  4. How long is a copy fresh?
  5. Vary: when one address has several answers
  6. What to send for each kind of file
  7. How to set the headers
  8. How to test what you sent
  9. What Lighthouse checks
  10. Mistakes to avoid
  11. Check your own page
  12. Sources

Where these headers sit

Every file your server sends comes with a block of response headers. A browser reads them to decide whether it may keep the file and for how long. A CDN or any other shared cache reads the same lines. (A service worker has a cache of its own that these headers do not control; see service workers and speed.) This is what a stylesheet's headers can look like after it passes through a CDN:

HTTP/1.1 200 OK
Content-Type: text/css
Cache-Control: max-age=31536000, immutable
ETag: "YsAIAAAA-QG4G6kCMAMBAAAAAAAoK"
Last-Modified: Tue, 22 Feb 2022 20:20:20 GMT
Vary: Accept-Encoding
Age: 120

You decide the first four lines after the content type. The server or your settings produce them. The Age line is added by the shared cache that held the copy. The big picture of the layers is in caching explained; this guide is about the lines themselves.

HeaderSent inWhat it does
Cache-ControlResponses, and sometimes requestsSays how long to keep a copy and who may keep it
ExpiresResponsesThe older way to give a date after which a copy is stale
ETagResponsesA label for this exact version of the file
Last-ModifiedResponsesWhen the server believes the file last changed
If-None-MatchRequestsThe browser's question: do you still have the version with this label?
If-Modified-SinceRequestsThe browser's question: has it changed since this date?
VaryResponsesWhich request headers change the answer
AgeResponsesHow many seconds a shared cache has held its copy
PragmaBothA leftover from HTTP/1.0

Cache-Control, directive by directive

Cache-Control is a comma-separated list of instructions. Directive names are not case sensitive, but MDN recommends lower case because some software does not recognize upper case. Numbers are whole seconds. These are the response directives you will meet:

DirectiveWhat it tells a cacheTypical use
max-age=NThe copy is fresh for N seconds after the server generated itAnything you want kept; 31536000 is one year
s-maxage=NThe same for shared caches only; it overrides max-age and Expires there, and browsers ignore itLet a CDN keep a page longer than browsers do
no-cacheYou may store it, but you must check with the server before every reuseHTML pages
no-storeDo not store it anywherePages with sensitive data
privateOnly the visitor's own browser may store itPages that differ per person
publicAny cache may store it, even if the request carried an Authorization headerRarely needed
must-revalidateOnce stale, never reuse it without checkingPaired with max-age
immutableThis file will not change while it is fresh, so do not check itFiles with a version in the name
stale-while-revalidate=NAfter it goes stale, it may be served for N more seconds while a fresh copy is fetched in the backgroundContent that can be a little old
stale-if-error=NAfter it goes stale, it may be served for N more seconds if the server answers with a 500, 502, 503 or 504Keeping pages up during an outage
no-transformNobody on the way may alter the contentFiles that must arrive byte for byte

Two points about max-age. It counts from when the response was generated on your server, not from when it arrived. If a CDN held the response for 100 seconds and says so with Age: 100, the browser deducts those 100 seconds from the lifetime. And a negative or non-whole number has no defined meaning, so write whole numbers.

The pairs people mix up

`no-cache` is not `no-store`. no-cache lets a cache keep the file but forces a check before each reuse; the check is cheap when the file has not changed. no-store forbids keeping the file at all. For a page that is the same for everyone, no-cache is almost always what you want. MDN also notes that max-age=0, must-revalidate means the same as no-cache.

`private` does not mean "do not cache". It is about where a copy may live: only in the visitor's browser, never in a shared cache. Use it on anything personalized. If you forget it on a page that is built from a login or a cookie, a shared cache may store that page and give it to the next visitor.

`max-age` is not `s-maxage`. The first is for every cache. The second is only for shared caches and wins over the first there. Together they let you say "browsers check each time, the CDN may keep it for an hour": max-age=0, s-maxage=3600.

`public` is rarely needed. A response is already storable in a shared cache when it has a max-age. The directive matters in one case: when the request carried an Authorization header, which normally keeps the response out of shared caches. s-maxage and must-revalidate lift that restriction too.

When directives clash

If directives contradict each other, the most restrictive one wins. MDN gives this example of a line that does nothing useful:

Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate

It is equivalent to Cache-Control: no-store. Long lists like this are often pasted from old advice. The WordPress admin and login screens send these same five directives, on purpose, because those pages must never be cached.

Which browsers understand the newer directives

Browsers ignore directives they do not recognize, so adding a newer one is safe.

Validation: ETag, Last-Modified and the 304

When a copy goes stale, the cache does not have to download the file again. It can ask the server whether the copy is still good. That check is called revalidation, and it uses two request headers that carry back the labels the server gave earlier.

If the label or the date still matches, the server answers 304 Not Modified with no body, which is a tiny response. The cache goes on using its copy and counts it fresh again. If not, the server answers 200 OK with the new file.

GET /app.css HTTP/1.1
If-None-Match: "YsAIAAAA-QG4G6kCMAMBAAAAAAAoK"

HTTP/1.1 304 Not Modified
Cache-Control: max-age=3600
ETag: "YsAIAAAA-QG4G6kCMAMBAAAAAAAoK"

Some details worth knowing:

How long is a copy fresh?

A cache works out the lifetime of a copy in this order:

  1. s-maxage, in a shared cache only.
  2. max-age.
  3. Expires, the HTTP/1.0 way: a fixed date in GMT such as Wed, 21 Oct 2015 07:28:00 GMT. It is ignored when Cache-Control has max-age or s-maxage. It was replaced because the date format is hard to parse, caused many bugs, and fails if a clock is wrong. You do not need to send it.
  4. A guess. With none of the above, but with a Last-Modified date, a cache may store the file anyway and reuse it for a while. This is heuristic caching, and the standard suggests about 10% of the time since the last change.

The guess is the reason to set a policy on every response. A file with no Cache-Control is not left uncached; it is cached for an unpredictable time.

The Age header shows how long a shared cache has held its copy. The remaining life is max-age minus Age: a response with max-age=604800 and Age: 86400 has 518,400 seconds left. An Age of 0 probably means the copy came from your server just now.

Pragma: no-cache is a leftover from HTTP/1.0. In a request it acts like Cache-Control: no-cache. In a response its meaning is not specified, so do not rely on it. Browsers send it themselves in one case: a force reload sends Pragma: no-cache and Cache-Control: no-cache.

Vary: when one address has several answers

Caches identify a response by its address. But the same address can give different answers: compressed or not, English or Spanish. The Vary response header lists the request headers that made the difference, and the cache then stores one copy per combination.

Send the same Vary on every response for an address, including the 304.

What to send for each kind of file

Kind of fileSendWhy
CSS, JavaScript, images, fonts with a version or hash in the nameCache-Control: max-age=31536000, immutableThe name changes with the content, so the old copy can never be wrong
Files without a version in the nameA short max-age, or no-cacheA changed file must reach visitors
An HTML page that is the same for everyoneCache-Control: no-cache plus an ETagAlways checked, rarely downloaded again
The same page, on a CDN you can purge when you publishCache-Control: max-age=0, s-maxage=3600Browsers check each time; the CDN serves its copy
A page that differs per visitorCache-Control: private, no-cacheOnly that visitor's browser keeps it, and only after a check
A page with data nobody else may ever seeCache-Control: no-storeNothing is kept
A list from an API that may be a minute oldCache-Control: max-age=60, stale-while-revalidate=300Visitors get an instant answer while it refreshes

Be careful with no-store. In most browsers it also keeps the page out of the back/forward cache, which makes the Back button instant. The 2025 Web Almanac found Cache-Control: no-store on 23% of sites. Chrome may allow the back/forward cache for some no-store pages when that is safe, but Firefox and Safari generally still treat the directive as a blocker. If the page only needs to be fresh and not secret, no-cache is the better choice.

How to set the headers

Where you set them depends on what serves the file.

Apache. The module mod_headers sets any header. The easy way to keep versioned files apart is to put a .htaccess file in the folder that holds them:

# .htaccess inside the folder with versioned files
<IfModule mod_headers.c>
  Header set Cache-Control "max-age=31536000, immutable"
</IfModule>

The mod_expires module is the older route: ExpiresActive On and then ExpiresByType image/gif A2592000, where the letter A counts the time from the visitor's request and M from the file's change date. It writes both Expires and Cache-Control: max-age. Note that Header applies only to successful responses unless you add the word always.

Nginx. The expires directive sets Expires and Cache-Control: max-age=... for responses such as 200, 301, 304 and a few others. A positive time gives a max-age, and a negative one gives no-cache. To send immutable or any other value, use add_header:

location /assets/ {
    # every file in this folder has a version or hash in its name
    add_header Cache-Control "max-age=31536000, immutable";
}

add_header has a trap. A block inherits the headers of the level above only if it defines none of its own. Adding one add_header to a block silently drops the other headers you set at the server level, such as security headers.

WordPress and other systems. WordPress core sends the no-cache list for logged-in users, the admin and login screens, 404 pages and password-protected posts. For ordinary pages seen by visitors who are not logged in, it sends no caching headers at all, so your page cache, host or CDN decides. Static files get their headers from the web server, the host's settings or the CDN.

A CDN. Many CDNs expose the same two lifetimes under their own names: how long the CDN keeps a file and how long it tells browsers to keep it. MDN notes that some CDNs read their own headers, such as Surrogate-Control, and that CDN-Cache-Control (RFC 9213) is the standard for this. A CDN may also ignore a header on purpose, so read its documentation instead of assuming. Some cache only static files until you tell them to cache pages as well.

Set the policy in one place. If the server, a plugin and the CDN each add a Cache-Control line, the cache reads all of them together, and the result is hard to predict.

How to test what you sent

Ask for the headers directly. This shows the policy headers of a file:

curl -s -o /dev/null -D - https://example.com/assets/app.3f9c1b.js | grep -i -E '^(cache-control|etag|last-modified|expires|age|vary)'

To check that revalidation works, send the ETag back. You should see 304:

curl -s -o /dev/null -D - -H 'If-None-Match: "YsAIAAAA-QG4G6kCMAMBAAAAAAAoK"' https://example.com/assets/app.3f9c1b.js | head -n 1

In Chrome, open the Network panel. A file served from the browser's cache shows (disk cache) or (memory cache) in the Size column. The Disable cache checkbox switches the browser cache off while DevTools is open, so untick it when you test caching.

What Lighthouse checks

In Lighthouse 13.5 the insight Use efficient cache lifetimes looks at fonts, images, media files, scripts and stylesheets that were answered with 200, 203 or 206. It reads the lifetime from max-age, or from Expires when there is no max-age. A lifetime of 30 days or more passes. A shorter one, or no header at all, is listed with an estimate of the bytes that a returning visitor would download again.

The check skips files that deliberately avoid caching: those with no-cache, no-store, must-revalidate or private, and those with Pragma: no-cache and no Cache-Control. It also skips files whose lifetime is already zero or over. A clean report therefore does not prove that your policy is good. In the Web Almanac's 2025 data, the median home page could have saved about 270 KB on mobile and 303 KB on desktop from better lifetimes. Those are potential savings for returning visitors, not a promise.

Mistakes to avoid

Check your own page

Run the free audit and look at the cache lifetime result, which lists the files that visitors download again on every visit. Check the policy of the page itself with the first command above: it should carry no-cache or a short lifetime, and an ETag. A slow first response for a page that is the same for everyone is often a missing page cache, which the time to first byte guide explains.

Sources

Start here

Free website audit

Live Google data for any public page: mobile and desktop.

Mobile + desktop · Lighthouse lab test · Real-user Chrome data when Google has it

Rather have this done for you? Speed optimization · Book a call

Related guides

All guides →