Skip to content

Best practices

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.

getReport teamUpdated 25 Sept 202612 min read

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

FilePolicyHeader
HTML pagesAlways check for a new versionCache-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 URLKeep for a year, never checkCache-Control: public, max-age=31536000, immutable
Static files without a version (/logo.png, /favicon.ico)Keep briefly, then checkCache-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 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 explains the rules in full.

How getReport checks it

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
Each file without a cache lifetime is listed with the Cache-Control header it sent, so you can see which rule is missing.

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.

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 covers the Lighthouse side.

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 and HTTP/2 and HTTP/3 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:

Caddyfile
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 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:
Shell
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 has the full set to repeat.
Check your site before and after Check