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.
In this guide
- Where these headers sit
- Cache-Control, directive by directive
- Validation: ETag, Last-Modified and the 304
- How long is a copy fresh?
- Vary: when one address has several answers
- What to send for each kind of file
- How to set the headers
- How to test what you sent
- What Lighthouse checks
- Mistakes to avoid
- Check your own page
- 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: 120You 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.
| Header | Sent in | What it does |
|---|---|---|
Cache-Control | Responses, and sometimes requests | Says how long to keep a copy and who may keep it |
Expires | Responses | The older way to give a date after which a copy is stale |
ETag | Responses | A label for this exact version of the file |
Last-Modified | Responses | When the server believes the file last changed |
If-None-Match | Requests | The browser's question: do you still have the version with this label? |
If-Modified-Since | Requests | The browser's question: has it changed since this date? |
Vary | Responses | Which request headers change the answer |
Age | Responses | How many seconds a shared cache has held its copy |
Pragma | Both | A 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:
| Directive | What it tells a cache | Typical use |
|---|---|---|
max-age=N | The copy is fresh for N seconds after the server generated it | Anything you want kept; 31536000 is one year |
s-maxage=N | The same for shared caches only; it overrides max-age and Expires there, and browsers ignore it | Let a CDN keep a page longer than browsers do |
no-cache | You may store it, but you must check with the server before every reuse | HTML pages |
no-store | Do not store it anywhere | Pages with sensitive data |
private | Only the visitor's own browser may store it | Pages that differ per person |
public | Any cache may store it, even if the request carried an Authorization header | Rarely needed |
must-revalidate | Once stale, never reuse it without checking | Paired with max-age |
immutable | This file will not change while it is fresh, so do not check it | Files with a version in the name |
stale-while-revalidate=N | After it goes stale, it may be served for N more seconds while a fresh copy is fetched in the background | Content that can be a little old |
stale-if-error=N | After it goes stale, it may be served for N more seconds if the server answers with a 500, 502, 503 or 504 | Keeping pages up during an outage |
no-transform | Nobody on the way may alter the content | Files 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-revalidateIt 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.
stale-while-revalidateis supported from Chrome 75, Firefox 68 and Safari 14.immutableis supported by Firefox from version 49 and Safari from version 11. Chrome does not implement it. It changed how it reloads pages instead, so that a normal reload no longer revalidates files that the page loads. In Chrome the directive does no harm and no good.
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.
- The
ETagresponse header is a label for one version of the file. The server decides how to make it. It is often a hash of the content, but it can also come from the modification time or a revision number. The browser stores it and, later, sends it back inIf-None-Match. - The
Last-Modifiedresponse header is a date. The browser sends it back inIf-Modified-Since.
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:
- When both validators are present, the server uses
If-None-Matchfirst. The HTTP standard prefers that a server send both headers.Last-Modifiedis also useful beyond caching: crawlers use it to adjust how often they visit a page. - A weak
ETagstarts withW/, as inW/"0815". It says two versions are equivalent in meaning but not byte for byte. Weak tags prevent caching of partial (range) downloads, so strong tags are better for files. - If several servers sit behind one address, they should send the same
ETagfor the same file. Otherwise a check that reaches a different server gets a full download instead of a 304. - Dates are always in GMT, and a server cluster has to keep the file times in step. This is one reason
ETagwas introduced as an alternative.
How long is a copy fresh?
A cache works out the lifetime of a copy in this order:
s-maxage, in a shared cache only.max-age.Expires, the HTTP/1.0 way: a fixed date in GMT such asWed, 21 Oct 2015 07:28:00 GMT. It is ignored whenCache-Controlhasmax-ageors-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.- A guess. With none of the above, but with a
Last-Modifieddate, 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.
Vary: Accept-Encodingis the common one. It goes with text compression, so a cache never hands a packed file to a client that cannot read it.Vary: Accept-Languagekeeps language versions of one address apart.- Avoid
Vary: User-Agent. There are so many different values that the cache rarely finds a match, and MDN advises detecting features instead. - Do not put
Vary: Cookieon personal pages to keep them out of shared caches. MDN says to useCache-Control: privatefor that. Vary: *means that something other than request headers shaped the answer, so the response cannot be reused.
Send the same Vary on every response for an address, including the 304.
What to send for each kind of file
| Kind of file | Send | Why |
|---|---|---|
| CSS, JavaScript, images, fonts with a version or hash in the name | Cache-Control: max-age=31536000, immutable | The name changes with the content, so the old copy can never be wrong |
| Files without a version in the name | A short max-age, or no-cache | A changed file must reach visitors |
| An HTML page that is the same for everyone | Cache-Control: no-cache plus an ETag | Always checked, rarely downloaded again |
| The same page, on a CDN you can purge when you publish | Cache-Control: max-age=0, s-maxage=3600 | Browsers check each time; the CDN serves its copy |
| A page that differs per visitor | Cache-Control: private, no-cache | Only that visitor's browser keeps it, and only after a check |
| A page with data nobody else may ever see | Cache-Control: no-store | Nothing is kept |
| A list from an API that may be a minute old | Cache-Control: max-age=60, stale-while-revalidate=300 | Visitors 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 1In 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
- No `Cache-Control` at all. Browsers and caches guess, and the guess differs between them.
- A long lifetime on an address that never changes, such as
/logo.pngwith a one-yearmax-age. - `no-cache` to mean "do not store". Use
no-storefor that, and only where it is needed. - Personal pages without `private`. They can end up in a shared cache.
- Duplicate headers from the server, a plugin and the CDN.
- Version numbers in a query string that your CDN ignores. Putting the version in the file name is safer.
- `immutable` on files that do change. It tells caches not to check, even on reload.
- Trusting Reload for tests, as described above.
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
- Cache-Control on MDN: every directive, the clashing example, and the browser support table.
- HTTP caching on MDN: freshness, validation,
Vary, reload behavior and the patterns for static files and main pages. - ETag, Last-Modified, Vary, Age, Expires and Pragma on MDN.
- Page weight chapter, Web Almanac 2025 and Performance chapter: cache lifetime savings and the use of
no-store.
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