# Cache-Control for humans: what to cache, how long, how to bust it

> Set browser caching once and stop sending returning visitors the same files again. Three policies cover every file, with configs for nginx, Apache, Caddy, Cloudflare and WordPress and a free checker.

Updated 2026-09-25 · Best practices · HTML version: https://getreport.app/guides/cache-control-for-humans

A returning visitor who opens your home page again should not download the logo, the stylesheet, the fonts and 300 KB of JavaScript again. Whether they do is decided by one response header, `Cache-Control`, and on most sites it is either missing or set the same way for every file. This guide explains the header in plain terms, gives the three policies that cover every file on a normal site, and shows how to change a cached file without waiting a year for it to expire. Allow 30 minutes plus testing.

## Quick answer

| File | Policy | Header |
| --- | --- | --- |
| HTML pages | Always check for a new version | `Cache-Control: no-cache` (or `max-age=300` if a 5-minute delay is fine) |
| CSS, JS, fonts, images with a version or hash in the URL | Keep for a year, never check | `Cache-Control: public, max-age=31536000, immutable` |
| Static files without a version (`/logo.png`, `/favicon.ico`) | Keep briefly, then check | `Cache-Control: public, max-age=86400` plus the server's `ETag` or `Last-Modified` |

- Change a long-cached file by changing its URL (`app.3f9a1c.css` or `style.css?ver=2.1`), never by editing it in place.
- Set it at the web server or CDN, once, by file type or folder.
- Check with the [HTTP/2 test](https://getreport.app/tools/http2-test) or `curl -I` on one file of each kind.

## Why caching matters

Without a cache lifetime, the browser has to ask the server about every file on every page view. At best it gets a short "not modified" answer, which still costs a round trip per file; at worst it downloads the file again. On a page with 60 files, that is 60 requests a returning visitor did not need, and on a phone each one waits for the network. With the right header, the same visit loads those files from the device in 0 ms and only the HTML goes over the network.

Caching also protects your server. Every file served from a browser or CDN cache is a request your origin never sees, which is why a traffic spike hurts a well-cached site much less. And it is free: the header is a few lines of configuration, set once.

The catch is that caching is a promise. `max-age=31536000` tells the browser "this file will not change for a year". If you then edit the file at the same URL, visitors keep the old version. That is why the policy depends on whether the URL changes when the content does.

### The directives, in plain words

- **`max-age=N`**: the file is fresh for N seconds after it was received. While fresh, the browser uses its copy without asking.
- **`s-maxage=N`**: the same, but only for shared caches such as a CDN. Lets the edge keep a page longer than browsers do.
- **`public`**: any cache may store it, including shared ones. **`private`**: only the visitor's browser may store it; use it for pages with personal content.
- **`no-cache`**: store it, but check with the server before every use. Despite the name, it caches; it just never skips the check.
- **`no-store`**: do not store it anywhere. For banking pages and responses with personal data, not for your logo.
- **`must-revalidate`**: once stale, never use the copy without checking, even if the server is unreachable.
- **`immutable`**: the file will not change while fresh, so do not check even when the visitor presses reload. Firefox and Safari honour it; Chrome already skips that check for page resources.
- **`stale-while-revalidate=N`**: after expiry, use the old copy for up to N more seconds while fetching a new one in the background. Supported by Chromium browsers, Firefox and several CDNs.

The older `Expires` header gives a fixed date instead of a lifetime; when both are present, `max-age` wins.

### Checking without downloading: ETag and Last-Modified

When a copy is stale (or marked `no-cache`), the browser does not have to download it again. It sends the `ETag` it received as `If-None-Match`, or the `Last-Modified` date as `If-Modified-Since`, and the server answers **304 Not Modified** with no body if nothing changed. That is the cheap check behind `no-cache` on HTML. nginx, Apache and Caddy send both headers for static files by default.

A file with neither `Cache-Control` nor `Expires` is not "not cached". Browsers then guess a lifetime from `Last-Modified`, typically a tenth of the time since the file last changed. That is the worst of both worlds: unpredictable, and different in every browser. MDN's [HTTP caching guide](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Caching) explains the rules in full.

## How getReport checks it

> **Free tool:** [HTTP/2 and HTTP/3 test](https://getreport.app/tools/http2-test): Check whether a site negotiates HTTP/2 during the TLS handshake and advertises HTTP/3, which protocol every file on the page actually used, plus compression, caching and CDN detection.

The HTTP/2 test (and every full report) loads the page in Chromium and records the response headers of every file. Two findings come out of the caching part, from two sources, plus a note on the CDN:

![The cache headers finding opened: the count of static files without a cache lifetime and a list of stylesheets, scripts, images and fonts with the Cache-Control value each one sent, or none](https://getreport.app/guides/img/cache-control-for-humans/finding.webp "Each file without a cache lifetime is listed with the Cache-Control header it sent, so you can see which rule is missing.")

> **Check: Static files have a cache lifetime.** Without a cache lifetime, every visit downloads the same logo, CSS and scripts again. Returning visitors should get them from their browser in 0 ms.
>
> 1. Send Cache-Control: public, max-age=31536000, immutable for versioned images, CSS, JS and fonts.
> 2. Most caching plugins and CDNs set this for you; check the "browser cache" or "edge TTL" setting.

This one counts the scripts, stylesheets, images and fonts the page loaded, yours and third-party. A file counts as cacheable when it has a positive `max-age` or `s-maxage`, `immutable`, or an `Expires` date in the future; `no-store` and a bare `no-cache` do not count. The finding appears when more than 20 % of the files have no lifetime, and the technical detail lists up to ten of them with the header they sent.

> **Check: Static files have long cache lifetimes.** Images, fonts, CSS and JS rarely change, yet without a long Cache-Control lifetime returning visitors download them again on every page.
>
> 1. Set Cache-Control max-age of a year on versioned static files (most CDNs and caching plugins have a "browser caching" switch).
> 2. Add a version or hash to file names so you can still change them.

This one comes from Lighthouse, in the Speed module. It also flags lifetimes that exist but are short (an hour on a stylesheet that never changes) and estimates the bytes a returning visitor could have loaded from cache. The two findings often appear together; the [cache TTL learn page](https://getreport.app/learn/cache-ttl) covers the Lighthouse side.

> **Check: A CDN is in front of the site.** A CDN serves your files from a location near each visitor and absorbs traffic spikes. Without one, visitors far from your server wait longer for every file.
>
> 1. Put the site behind a CDN such as Cloudflare, Bunny or Fastly; the free tiers cover most small sites.
> 2. At minimum, serve images and static files from a CDN hostname.

The CDN is recognised from its response headers (`cf-ray`, `x-amz-cf-id`, `x-fastly-request-id` and similar). It costs no points; it is there because the CDN is usually where the cache headers are set. Compression and the protocol are usually set in the same place, so the same test reports them too; [text compression with gzip and Brotli](https://getreport.app/guides/text-compression-gzip-brotli) and [HTTP/2 and HTTP/3](https://getreport.app/guides/http2-and-http3) cover those.

## Step by step

### 1. Sort your files into the three groups

Look at the list in the finding. For each file, ask one question: **does its URL change when its content changes?**

- `app.3f9a1c.css`, `/_next/static/…`, `/assets/index-B7kq2.js`: a hash in the name. Group 2, a year.
- `style.css?ver=6.6.2`: a version in the query string. Group 2 in most setups (see step 4).
- `/logo.png`, `/images/team.jpg`, `/favicon.ico`: same URL forever. Group 3, a day plus validation, unless you promise to rename the file whenever it changes.
- The HTML page itself: group 1.

Build tools (Vite, webpack, Next.js, Astro) hash file names by default, which is why their output can be cached for a year safely.

### 2. Set the headers on the server

**nginx.** In the `server` block, for example in `/etc/nginx/sites-available/example.com`:

```nginx
# Hashed build output: a year, never revalidated
location ^~ /assets/ {
    add_header Cache-Control "public, max-age=31536000, immutable";
    try_files $uri =404;
}

# Other static files: CSS/JS with ?ver=, fonts, images
location ~* \.(?:css|js|mjs|woff2?|png|jpe?g|gif|webp|avif|svg|ico)$ {
    add_header Cache-Control "public, max-age=31536000, immutable";
    try_files $uri =404;
}

# HTML: always revalidate (ETag/Last-Modified make that cheap)
location / {
    add_header Cache-Control "no-cache";
    try_files $uri $uri/ /index.html;
}
```

Adapt the last `try_files` to your application (WordPress uses `/index.php?$args`). If you have images that change at the same URL, give them their own `location` with `max-age=86400`. One nginx rule catches almost everyone once: an `add_header` inside a `location` replaces **all** `add_header` lines from the `server` level, so your security headers disappear from those files unless you repeat them in each block (or keep them in a snippet you `include`). Test with `nginx -t`, then reload.

**Apache.** In `.htaccess` or the virtual host, with `mod_headers` enabled:

```apache
<IfModule mod_headers.c>
    # Versioned CSS/JS, fonts and images: a year, never revalidated
    <FilesMatch "\.(css|js|mjs|woff2?|png|jpe?g|gif|webp|avif|svg|ico)$">
        Header set Cache-Control "public, max-age=31536000, immutable"
    </FilesMatch>
    # Static HTML files: always revalidate
    <FilesMatch "\.html?$">
        Header set Cache-Control "no-cache"
    </FilesMatch>
</IfModule>
```

If only `mod_expires` is available, it sets the lifetime by type (it writes both `Expires` and `max-age`, without `immutable`):

```apache
<IfModule mod_expires.c>
    ExpiresActive On
    ExpiresByType text/css "access plus 1 year"
    ExpiresByType text/javascript "access plus 1 year"
    ExpiresByType font/woff2 "access plus 1 year"
    ExpiresByType image/webp "access plus 1 year"
    ExpiresByType image/jpeg "access plus 1 year"
    ExpiresByType image/png "access plus 1 year"
    ExpiresByType image/svg+xml "access plus 1 year"
</IfModule>
```

**Caddy.** In the `Caddyfile`, with named matchers so the two rules never overlap:

```caddy
example.com {
    root * /var/www/example

    @static path *.css *.js *.mjs *.woff2 *.png *.jpg *.jpeg *.webp *.avif *.svg *.ico
    header @static Cache-Control "public, max-age=31536000, immutable"

    @html path *.html */
    header @html Cache-Control "no-cache"

    file_server
}
```

Caddy's file server sends `ETag` and `Last-Modified`, so the `no-cache` HTML check costs a 304, not a download.

### 3. Set the CDN

A CDN keeps its own copy at the edge, and it has two lifetimes: how long it keeps the file (edge TTL) and what it tells browsers (browser TTL).

On **Cloudflare**, static file types are cached at the edge by default and HTML is not. Caching → Configuration → Browser Cache TTL set to "Respect existing headers" passes your origin's `Cache-Control` through unchanged, which is what you want once step 2 is done. To cache HTML at the edge as well, add a Cache Rule (Caching → Cache Rules) with an edge TTL, or send `s-maxage` from the origin, and purge the cache when you publish. Cloudflare also has a caching level setting that can ignore query strings; if you version files with `?ver=`, keep the standard level so a new version is a new cache entry.

If the finding showed no CDN, the [CDN learn page](https://getreport.app/learn/cdn) explains when one is worth adding; for a small site with visitors in one country, good headers matter more.

### 4. Bust the cache by changing the URL

When a year-cached file changes, give it a new URL; every visitor fetches the new one on their next page view because the HTML (which is revalidated) now points at it.

- **File name hash** (`app.3f9a1c.css`): the build tool does it; every cache, CDN and proxy treats it as a new file. The most reliable option.
- **Query string** (`style.css?ver=2.1`): what WordPress does for every theme and plugin file. It works in browsers and in most CDNs, but a cache configured to ignore query strings serves the old file to everyone.

Never edit a year-cached file in place and hope. Visitors who have it keep it until the lifetime runs out.

## Platform notes

### WordPress

Page caching plugins (WP Rocket, W3 Total Cache, LiteSpeed Cache, WP Super Cache) store rendered HTML on the server. That speeds up the HTML response but does not set browser lifetimes for your CSS, JS and images; that is a separate "browser cache" setting in some of them. On Apache, the plugin can write the rules into `.htaccess`. On nginx it cannot: the host or whoever manages the server has to add the rules from step 2.

Theme and plugin files are loaded as `style.css?ver=6.6.2`. The version changes when the theme or plugin updates, so a year's lifetime is safe for them. Avoid plugins or "speed tips" that remove `?ver=` from static files: with a long lifetime and no version, visitors keep the old CSS after every update. Media Library images get a new file name when you upload a replacement, so a year is fine for `wp-content/uploads` too.

### Shopify

Shopify serves theme files and images from its own CDN with its own cache headers and version parameters. There is nothing to configure; if the finding lists files, they are usually third-party app scripts.

### Static sites and frameworks

Next.js marks `/_next/static/` as immutable automatically. On Netlify, set headers in `_headers`; on Vercel, in `vercel.json`. Both already cache hashed assets well; the HTML policy is the one to check.

## Verify

- Run the HTTP/2 test again. The cache finding reads "Static files have a cache lifetime", or lists only third-party files.
- Check one file of each group from a terminal:

```bash
curl -sI https://www.example.com/wp-content/themes/mytheme/style.css?ver=2.1 | grep -iE "cache-control|etag|last-modified|age"
curl -sI https://www.example.com/ | grep -iE "cache-control|etag"
```

- In the browser's developer tools, Network tab: open a page, then click to another page on the site. Stylesheets, scripts and fonts should show "(memory cache)" or "(disk cache)" in the Size column.
- In a new report, the Speed module's cache finding is gone or lists far fewer files.

## Common mistakes

- **A long `max-age` on HTML that changes.** Symptom: you publish, and visitors still see the old page for hours. HTML gets `no-cache` or a few minutes at most; cache it longer only at the CDN, with a purge on publish.
- **`no-store` on everything.** Often a security plugin or a framework default. Every visit then downloads everything. Keep `no-store` for pages with personal data.
- **`Vary: *`.** It tells every cache that no two requests are alike, which makes the response uncacheable. `Vary: Accept-Encoding` is normal; anything broader needs a reason.
- **Forgetting fonts.** Fonts are often served by a rule written before `.woff2` existed and get no lifetime. They never change; give them a year.
- **Security headers vanishing after adding a cache rule in nginx.** The `add_header` inheritance rule above. Re-check them after any cache change; [security headers from zero](https://getreport.app/guides/security-headers-from-zero) has the full set to repeat.
