Use efficient cache lifetimes in Lighthouse: how to fix it
Lighthouse 13 lists every file your visitors' browsers can reuse for less than 30 days. What that costs on the next page and the return visit, and the fix per server and platform.
Does "Use efficient cache lifetimes" hurt my Lighthouse score?
Lighthouse flags every static file with a cache lifetime under 30 days in the Use efficient cache lifetimes insight ("A long cache lifetime can speed up repeat visits to your page"), called "Serve static assets with an efficient cache policy" before Lighthouse 13 and "Leverage browser caching" in older PageSpeed Insights. It doesn't count in your score and can't move it: Lighthouse always loads with an empty cache. Set longer lifetimes on your server, CDN or caching plugin.
Table of Contents!
What does Use efficient cache lifetimes mean in Lighthouse?
Lighthouse's description says: "A long cache lifetime can speed up repeat visits to your page. Learn more about caching."
Lighthouse lists every script, stylesheet, font, image, video and audio file with a cache lifetime under 30 days, or with no lifetime at all. The lifetime is the max-age in the file's Cache-Control header, or else the time until its Expires date. Files from other domains are listed like your own.
Lighthouse leaves out a file whose Cache-Control says no-cache, no-store, private or must-revalidate, whatever its max-age: it takes those as a choice not to cache. It also leaves out a file with max-age=0, and it never lists the HTML page itself.

A page that loads Google Tag Manager, GA4 and the Meta Pixel. Google sends its tags as private, so of the three only the Meta Pixel is listed. Lighthouse 13.5.0, simulated throttling as in PageSpeed Insights.
How does browser caching work?
The browser stores a file together with its lifetime. On the next page or the next visit it looks at its stored copy first:
- Within its lifetime: the browser uses its copy and sends no request at all. This is the only case without a wait for the server.
- Lifetime over: the browser asks the server whether the file changed, with the ETag or Last-Modified date it got with the file. If it didn't, the server answers 304 Not Modified, without the file. That saves the download, but every file still costs a round trip to the server, and the page can't paint until the server has answered for every stylesheet.
- Lifetime over and no ETag or Last-Modified: the browser downloads the file again.
| Header on the file | Next view in Chrome |
|---|---|
max-age=31536000 | From the cache for a year |
max-age=14400 | From the cache for 4 hours, then a 304 |
no-cache | A 304 every time |
no-store | Downloaded every time |
A normal reload in Chrome doesn't recheck files that are still within their lifetime. Firefox and Safari do, unless the file has immutable in its Cache-Control.
Since Chrome 86 every site has its own cache. A font or a jQuery file your visitor already downloaded on another site is downloaded again on yours, even from the same URL. So a third party's lifetime only helps on your own site, on the next page and the return visit, and "everyone has it cached already" is no reason to load fonts from Google anymore (host them yourself).
The browser cache and your CDN's cache are two different things, and so is the page cache of a WordPress caching plugin. A CDN or a page cache keeps a copy so your server doesn't have to send or build it for every visitor, which shortens the TTFB (cache duration). The browser cache saves the whole request, for one visitor. This insight is only about the browser cache, so a page cache that is already on doesn't clear it.
Does a longer cache lifetime improve LCP and FCP?
FCP and LCP on a first visit
No change. On a WooCommerce product page, on a phone over Slow 4G, the page painted first at 2.13 s and LCP came at 2.26 s with every header, from none to a year: on a first visit nothing is in the cache yet. Lighthouse and PageSpeed Insights only ever load a page this way. Fixing this insight never moves your Lighthouse score or metrics, even when Lighthouse shows it in red with FCP and LCP savings next to it.

No lifetime

Cloudflare's 4 hours

One year

no-store
The same product page with four cache headers on its files. Lighthouse 13.5.0, mobile, simulated throttling.
FCP and LCP on the next page and a return visit
The first paint (FCP) in seconds, against 2.13 s on the first visit:
| Cache header | Next page | A week later |
|---|---|---|
| a year | 0.79 | 0.78 |
| 4 hours | 0.80 | 1.94 |
no-cache | 1.93 | 1.94 |
no-store | 2.11 | 2.12 |
- The gain comes from files within their lifetime. A week later the page with a year's lifetime painted at 0.78 s, about a third of the first visit. What is left is the wait for the HTML. On the return visit LCP, the product photo, came at the same moment as the first paint with every header except no-store (2.24 s).
- With 304s the return visit was nearly as slow as a first visit. Without a lifetime, a week later all 13 of the shop's own files came back as 304: 1 kB over the network instead of 404 kB, and still a first paint at 1.94 s, only 0.19 s sooner than downloading everything again.
- Short lifetimes cover the visit and run out before the return. Cloudflare's 4 hours served the next page from the cache. A week later it needed 304s. Only the year covered both.
- no-cache and no-store slow down every page. They took the next page from 0.79 s to 1.93 s and 2.11 s.

No lifetime, a week later

A year's lifetime, a week later
Both pages at 0.8 s on the throttled phone.
On a real Slow 4G connection every view takes longer; the order between the headers stays the same. On Fast 4G a year's lifetime painted at 0.52 s on the return visit, and the return with 304s took 0.85 s, as long as the first visit on that connection.
When can you ignore Use efficient cache lifetimes?
When Lighthouse lists your own files and your visitors come back days or weeks later, fix it: it takes one rule on the server and costs nothing while the page loads. Leave it alone when:
- every file it lists is on another domain or served by your platform (see below); on Shopify it always is;
- your CSS and JavaScript change under the same file name and you can't add a version: then a short lifetime is the right choice, and Lighthouse keeps listing those files;
- almost every visitor sees one page and never comes back, such as a landing page for an ad campaign: nobody uses the cache.
How do you measure the effect of cache lifetimes in your field data?
Your field data mixes first visits with next pages and return visits. In CoreDash, group LCP on mobile by Repeat Visitor, then TTFB the same way:


CoreDash on a demo site, acme-pagespeed.com: mobile, p75.
Returning visitors have an LCP of 0.65 s, new visitors 1.74 s. Not all of that gap is the cache: returning visitors also get the first byte sooner, 323 ms against 0.87 s, often because their browser already has a connection to your server. So subtract the TTFB from the LCP. After the first byte, returning visitors wait 0.33 s and new visitors 0.87 s. That 0.54 s is roughly what the cache saves, about half of the gap; the faster first byte is the other half.
To see the next page of a visit on its own, group by Navigation Origin: same origin means the visitor came from another page on your site. CrUX puts all these visits into one p75 per page or per site, so it can't show this split.
How do you check a file's cache headers in DevTools?
Open the Network panel and make sure Disable cache is off (the DevTools network settings say when to switch it on). Load your page, then click through to a second page. The Status and Size columns show what happened to each file:
- (disk cache) or (memory cache) in Size: taken from the cache, no request.
- 304 in Status: the browser asked the server and got no file back.
- 200 with a size: downloaded again.

No lifetime: 304 for every file

One year: from the disk cache
The return visit a week later in the Network panel, Slow 4G.
Click a file and open its Headers tab. Under Response Headers, cache-control and expires show the lifetime your server sent, etag and last-modified what the browser can recheck with. From the command line:
curl -sI https://www.example.com/wp-content/themes/acme/style.css | grep -iE "cache-control|expires|etag|last-modified" The Performance panel has the same insight. Record a reload and open Use efficient cache lifetimes in the Insights sidebar: it lists the files and outlines them in the Network track. A file without a lifetime shows as 0 ms there, and as None in Lighthouse. It goes by the headers, so a file that came from the cache is still listed when its lifetime is short.

How do you fix Use efficient cache lifetimes?
The domain of each file Lighthouse lists tells you who sets the header: on your own domain that is your server, your host or your CDN; on any other domain it is the vendor of that file.
nginx, Apache, IIS and Caddy
On your own server, one rule per file type sets the lifetime. These rules give a year, with immutable: use them for images, and for CSS, JavaScript and fonts once their URLs carry a version (which lifetime for which file). In nginx:
location ~* \.(?:css|js|mjs|woff2|svg|png|jpe?g|gif|webp|avif|ico)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
} Put it in the server block that serves these files from disk. If your config already has a location for these extensions, add the add_header line to that one instead, because nginx uses only the first regex location that matches. Remove any expires line from it, or the files get two Cache-Control headers. An add_header in a location makes nginx drop every add_header of the server block for those files, so copy the server block's add_header lines (your HSTS header, for example) into this location too.
In Apache, put this in .htaccess:
<IfModule mod_headers.c>
<FilesMatch "\.(css|js|mjs|woff2|svg|png|jpe?g|gif|webp|avif|ico)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
</IfModule> FilesMatch goes by the file name, so the pages PHP builds keep their own headers. If Lighthouse still lists the files afterwards, your server doesn't apply the rule: mod_headers is off, or your host serves these files from another server that ignores .htaccess. Ask your host.
IIS sends no lifetime at all by default. Set the header in web.config, scoped to the folder with your static files:
<configuration>
<location path="assets">
<system.webServer>
<httpProtocol>
<customHeaders>
<add name="Cache-Control" value="public, max-age=31536000, immutable" />
</customHeaders>
</httpProtocol>
</system.webServer>
</location>
</configuration> In Caddy:
@static path *.css *.js *.mjs *.woff2 *.svg *.png *.jpg *.jpeg *.gif *.webp *.avif *.ico
header @static Cache-Control "public, max-age=31536000, immutable" WordPress
WordPress itself sends no lifetime for anything in /wp-content/ or /wp-includes/: your host, your server or a plugin decides. Kinsta, WP Engine, WordPress VIP and WordPress.com already send a year: if you host there, the files Lighthouse lists are usually on other domains (see below). The notice usually shows up on shared Apache hosting that sends no lifetime, on a server you run yourself with nginx, and behind Cloudflare's default. In the caching plugins:
- WP Rocket writes mod_expires rules into .htaccess on Apache and LiteSpeed servers: a year for CSS and JavaScript, four months for images and fonts. On nginx it does nothing: add the nginx rule above, or ask your host.
- LiteSpeed Cache has Browser Cache switched off by default. Switch it on under LiteSpeed Cache, Cache, Browser; it then sends a year.
- W3 Total Cache has Browser Cache switched on by default. On nginx it writes an nginx.conf that you have to include in your server configuration yourself.
WordPress adds ?ver= to every stylesheet and script it loads: the WordPress version by default, or the version the theme or plugin passes. That changes only on an update. When you edit your own CSS without changing the version, the URL stays the same and returning visitors keep the old CSS for as long as its lifetime runs. Load your own files with their modification time as the version, so the URL changes on every edit:
add_action( 'wp_enqueue_scripts', function () {
$file = '/css/custom.css';
wp_enqueue_style( 'acme-custom', get_stylesheet_directory_uri() . $file, array(), filemtime( get_stylesheet_directory() . $file ) );
} ); Uploads have no version. WordPress gives a file you upload again a new name (photo-1.jpg), so a year is safe for them, unless a plugin replaces media under the same name.
Cloudflare and other CDNs
Cloudflare's Browser Cache TTL is 4 hours by default, on every plan. When your server sends no lifetime or a shorter one, Cloudflare sends 4 hours, and Lighthouse lists the file as "4h". Set the lifetimes on your server and switch Browser Cache TTL (Caching, Configuration) to Respect Existing Headers. Or create a Cache Rule for your static file types that sets the browser TTL. Don't raise the zone-wide setting to a year: it then applies to every file Cloudflare serves with a shorter header, unversioned CSS included. More on the other settings in configure Cloudflare for the Core Web Vitals.
CloudFront and Fastly pass your server's header on to the browser; their TTL settings only change how long the CDN keeps its copy. On Bunny, an Edge Rule with Override Browser Cache Time sets the lifetime per file extension.
Shopify
Shopify already gives your theme files, uploaded files and product images a year (public, max-age=31557600), and you can't change Shopify's headers. What Lighthouse lists on Shopify comes from Shopify itself and your apps: the dynamic checkout buttons (portable-wallets, 5 minutes, on your own domain under /cdn/shopifycloud/), the shop-js loader (30 minutes) and the web pixels manager (60 seconds). There is nothing to fix. Ignore the notice.
Next.js and other build tools
Next.js gives everything under /_next/static/ a year with immutable, and you can't override that. Files in public/ get max-age=0 (on Vercel with must-revalidate) and /_next/image sends must-revalidate, so the insight skips both. On a Next.js site, the files Lighthouse lists come from third parties, or from a proxy or CDN in front that rewrites the headers. For files in public/ whose name changes with their content, set a year in next.config.js:
module.exports = {
async headers() {
return [{
source: '/fonts/:path*',
headers: [{ key: 'Cache-Control', value: 'public, max-age=31536000, immutable' }],
}]
},
} On any other stack, let the bundler put a hash in the file names (app.3f9a1c.js), so every change gets a new URL. Then send a year on those files.
Third-party and platform files you can't change
A vendor keeps the lifetime of its script short so it can update it on every site at once, and you can't change its headers. Lighthouse often lists these, with the lifetime their vendor sends:
- Meta Pixel (fbevents.js): 20 minutes
- Hotjar: 60 seconds
- Google Analytics' old analytics.js: 2 hours
- Google Maps (maps/api/js): 30 minutes
- Stripe (js.stripe.com/v3): 2 minutes
- Cloudflare's own scripts on your domain (email-decode.min.js, Rocket Loader): 2 days
Leave these. Don't host a loader such as fbevents.js or gtag.js yourself to get your own header on it: your copy stops getting the vendor's updates. What you can do:
- Load a library from a public CDN with an exact version in the URL. jsDelivr gives swiper@11 seven days and an exact version a year. Or host that pinned version yourself, under your own header.
- Switch off a Cloudflare feature you don't need, such as Email Address Obfuscation.
- Remove a tag nobody uses anymore.
Which cache lifetime to set for which file
- CSS, JavaScript and fonts with a version or hash in the URL: a year, with immutable:
Cache-Control: public, max-age=31536000, immutable. A new version is a new URL, so the browser fetches it right away. - Images: the same header. Give a changed image a new file name, and nobody ever sees the old one.
- CSS, JavaScript and fonts without a version: add one first (the WordPress and build tool steps above). Until you can, keep the lifetime short and accept that Lighthouse lists them: with a long lifetime on a stylesheet that changes under the same name, returning visitors get your new HTML with the old CSS.
- The HTML:
Cache-Control: no-cacheor a short max-age, because with a long lifetime visitors would keep an old page. - Never no-store, no-cache or must-revalidate on static files to clear the notice, because every page view then rechecks or downloads every file again (the next page above).
How do you check that your new cache lifetimes work?
Check the header on the file first, with curl or the Headers tab (above): it should show the new max-age. Then run Lighthouse on mobile. It no longer lists the files, and the score and metrics stay where they were, as in the four reports above. On the product page the score came out at 96 or 80 from run to run, and which one had nothing to do with the header, so a different score after the fix tells you nothing about the cache.
Returning visitors in CoreDash show it first, within days, as they come back and get the files with their new lifetime. CrUX and Search Console show the last 28 days, so two weeks after the fix half of what they show is still the old site.
Not sure which of the listed files are yours to fix, or whether your returning visitors wait for files they already have? In a Core Web Vitals power hour we go through your headers and your field data together, and you get the rules for your server before we hang up.
Find out what is actually slow.
I map your critical rendering path using real field data. You get a prioritized fix list, not a Lighthouse report.
Get the audit