How to Fix the Leverage Browser Caching Warning (Use Efficient Cache Lifetimes)

Quick Summary
- Lighthouse 13 and PageSpeed Insights call this warning Use efficient cache lifetimes; it lists images, fonts, CSS and JavaScript cached for less than 30 days.
- On Apache, give each static MIME type a one-year lifetime with mod_expires in .htaccess, including text/javascript, SVG and fonts; on Nginx, use expires 1y.
- Add immutable only to URLs that change with each version, such as WordPress ?ver= files, and expect third-party rows to stay, since only their providers can fix them.
The old “Leverage browser caching” warning is called Use efficient cache lifetimes in Lighthouse 13 and PageSpeed Insights. Before Lighthouse 13 it was Serve static assets with an efficient cache policy. It lists images, fonts, CSS and JavaScript files that the browser may keep for less than 30 days. To fix it, have your server send a long Cache-Control: max-age (a year is common) for static files: with mod_expires in .htaccess on Apache, or with expires in the server configuration on Nginx. Then give CSS and JavaScript files a version in their URL, so visitors still get your changes.
What the warning checks
The check (the cache-insight audit in Lighthouse 13.5) works like this:
- It looks at fonts, images, media, scripts and stylesheets that load with a 200, 203 or 206 status. HTML pages are not part of it.
- It skips files whose headers switch caching off on purpose, with
no-cache,no-store,must-revalidateorprivate. - It reads the lifetime from
Cache-Control: max-age, or from theExpiresheader when there is nomax-age. Files with no lifetime, or one shorter than 30 days, are listed. - It lists files from every domain, including other companies’ servers. Those are covered further down.
Apache: cache headers in .htaccess
Add this block to the .htaccess file in your site’s root folder, outside the # BEGIN WordPress and # END WordPress markers, because WordPress may rewrite that section:
## EXPIRES HEADER CACHING ##
<IfModule mod_expires.c>
ExpiresActive On
# Images, icons and video
ExpiresByType image/jpeg "access plus 1 year"
ExpiresByType image/png "access plus 1 year"
ExpiresByType image/gif "access plus 1 year"
ExpiresByType image/webp "access plus 1 year"
ExpiresByType image/avif "access plus 1 year"
ExpiresByType image/svg+xml "access plus 1 year"
ExpiresByType image/x-icon "access plus 1 year"
ExpiresByType video/mp4 "access plus 1 year"
ExpiresByType video/webm "access plus 1 year"
# Fonts
ExpiresByType font/woff2 "access plus 1 year"
ExpiresByType font/woff "access plus 1 year"
ExpiresByType font/ttf "access plus 1 year"
ExpiresByType font/otf "access plus 1 year"
# CSS and JavaScript (servers send .js as either type)
ExpiresByType text/css "access plus 1 year"
ExpiresByType text/javascript "access plus 1 year"
ExpiresByType application/javascript "access plus 1 year"
# Documents
ExpiresByType application/pdf "access plus 1 month"
</IfModule>
<IfModule mod_headers.c>
# CSS and JavaScript with ?ver= in the URL get a new URL when the version changes
<If "%{QUERY_STRING} =~ /(^|&)ver=/ && %{REQUEST_URI} =~ /\.(css|js|mjs)$/">
Header set Cache-Control "max-age=31536000, immutable"
</If>
</IfModule>
## EXPIRES HEADER CACHING ##mod_expiressends theExpiresheader andCache-Control: max-age.access plus 1 yearcounts from the moment the browser requests the file, so a year comes out asmax-age=31536000and a month asmax-age=2592000(30 days). The<IfModule>wrappers skip a block when its module isn’t enabled.- Each
ExpiresByTypeline must match the MIME type your server actually sends. Apache 2.4.68’s ownmime.typessends.jsfiles astext/javascript, while older setups and Nginx useapplication/javascript, so the block lists both. SVG isimage/svg+xml, and fonts use thefont/types. - There is no
ExpiresDefaultline on purpose. A default also applies to HTML pages, including the pages WordPress builds for visitors, which carry no cache headers of their own. Browsers could then show visitors an old copy of a page. - The
mod_headerspart addsimmutableto CSS and JavaScript URLs that containver=. WordPress adds?ver=to enqueued files (the WordPress version when a plugin or theme doesn’t set one), so a new version means a new URL.immutabletells the browser the file won’t change while it is fresh, so it doesn’t ask the server again even when the visitor reloads the page.
These rules were tested on a throwaway Apache 2.4.68 server. Images, fonts, CSS and JavaScript got max-age=31536000; style.css?ver=1.2 got max-age=31536000, immutable; HTML pages, a missing .css file routed through a WordPress-style rewrite (404), and a page that sent its own no-cache headers got no caching headers from these rules.
If you copied the older version of this block
The block this post used to show listed application/javascript but not text/javascript, used image/svg instead of image/svg+xml, and had no lines for AVIF or fonts. On Apache 2.4.68, JavaScript, SVG, AVIF and WOFF2 files all fell through to its ExpiresDefault "access 2 days", which Lighthouse still flags, and the same two days applied to HTML pages. The image/jpg.webp and application/x-shockwave-flash lines matched nothing useful (Flash reached end of life in 2020). Replace the old block with the one above.
Nginx: expires and Cache-Control
Nginx doesn’t read .htaccess files, so the rules go in the server configuration:
# In the http block, outside any server block
map $arg_ver $asset_cache_control {
"" "max-age=31536000";
default "max-age=31536000, immutable";
}
# In the server block of your site
location ~* \.(?:jpe?g|png|gif|webp|avif|svg|ico|mp4|webm|woff2?|ttf|otf)$ {
expires 1y;
access_log off;
}
location ~* \.(?:css|js|mjs)$ {
add_header Cache-Control $asset_cache_control;
access_log off;
}expires 1ysendsExpiresandCache-Control: max-age=31536000for images, video and fonts.- For CSS and JavaScript, the
maplooks at theverquery argument: URLs with?ver=getimmutableas well, others get only the year. add_headerhas a catch: alocationthat has its ownadd_headerdrops everyadd_headerfrom theserverlevel. If your server block sets security headers such asX-Frame-Options, repeat them inside the CSS and JavaScript location. Nginx 1.29.3 addedadd_header_inherit merge;to keep them instead.- Run
nginx -tbefore you reload Nginx.
On a throwaway Nginx 1.26.1 server, images and fonts got max-age=31536000 plus an Expires date a year ahead, app.js?ver=7.1.2 got max-age=31536000, immutable, and the server-level X-Frame-Options header disappeared from CSS and JavaScript responses until it was repeated inside that location.
Keep updates reaching visitors
With a lifetime of a year, returning visitors keep their copy of a file until its URL changes. WordPress core changes the ?ver= number with every WordPress release, and plugins that pass their own version number change it when they update. For your own theme files, change the version whenever you change the file, or use the file’s modification time as the version, as shown in fixing CSS that doesn’t update in the browser. If you edit CSS or JavaScript in place without changing the URL, use access plus 1 month for those two types instead: 30 days is still enough for Lighthouse.
Caching plugins may already write these rules for you. WP Rocket, for example, adds cache lifetimes for static files to .htaccess on Apache. Check the file first so you don’t end up with two blocks that disagree.
Third-party files you can’t fix
Lighthouse also lists files loaded from other companies’ servers: analytics and ad scripts, embedded video players, chat widgets, and fonts from a font service. Their cache headers are set by whoever serves them. Nothing in your .htaccess or Nginx configuration reaches those files, so these rows stay in the report after you fix your own.
- Remove scripts the site no longer needs.
- Self-host files that are meant to be self-hosted, such as web fonts. Adding fonts with @font-face shows how, and your own cache rules then apply to them.
- Don’t copy a provider’s script to your server just to change its headers. Your copy stops receiving the provider’s updates.
CDN settings
A CDN sits between your server and the browser and can change these headers. In Cloudflare, the Browser Cache TTL setting (on the Caching page) replaces your server’s value when yours is lower than the setting, or when your server sends no Cache-Control or Expires header at all. With Respect Existing Headers, Cloudflare leaves your headers alone. The Edge Cache TTL is a different setting: it controls how long Cloudflare keeps its own copy and doesn’t show up in the browser’s headers. With another CDN, look for its browser cache or header settings before you blame your server rules.
After you change the rules, purge the CDN’s cache. Copies it already holds keep the old headers until they expire.
Check the headers
Open the page in Chrome, open DevTools (F12), go to the Network tab, reload, click a CSS or image file and look at Response Headers. From a terminal:
curl -I "https://example.com/wp-includes/js/jquery/jquery.min.js?ver=3.7.1"With the rules above, the response includes Cache-Control: max-age=31536000, immutable. Then run PageSpeed Insights again. Your own files should be gone from Use efficient cache lifetimes; the rows that remain belong to other domains. Google’s documentation for this insight explains the check in more detail.
For caching and other Core Web Vitals fixes across a whole site, see my WordPress SEO and performance services.
Related articles
- a www to non-www HTTPS redirect: another rule for the same .htaccess file
- adding defer to WordPress scripts: another common speed test warning
- fixing text that stays invisible while web fonts load: the font-display warning from the same report
