Docs
Cache files in the browser
Cache files in the browser
Every page pulls in the same stylesheets, scripts, logos and fonts. Without a cache header the browser asks for them again on every visit and on every click to the next page: more requests, slower pages for returning visitors, and more traffic on your server. A Cache-Control header tells the browser it may keep a file and reuse it without asking.
The site checklist looks at up to six of the site's own files that the homepage loads (CSS, JavaScript and images from the same address as the page) and expects each to be cached for at least 7 days.
What counts as cached
| Header on the file | Result |
|---|---|
Cache-Control: public, max-age=31536000, immutable |
OK, the best case for fingerprinted files |
Cache-Control: max-age=2592000 (30 days) |
OK |
Cache-Control: max-age=604800 (7 days) |
OK, the minimum |
Expires date at least 7 days ahead, no max-age |
OK |
Cache-Control: max-age=3600, or no header at all |
not cached long enough |
Cache-Control: no-cache or no-store |
not cached, whatever else the header says |
Files from other domains (Google Fonts, a script CDN, an ad network) are not checked: you cannot change their headers. A separate subdomain such as static.example.com also counts as another address.
How long is safe depends on the file name:
- Fingerprinted names such as
app-3f2a9c1d.cssor/_next/static/…change whenever the content changes. Cache them for a year withimmutable; a new version is a new file. - Names with a version in the query, such as
style.css?ver=6.6.2(WordPress adds this to every enqueued stylesheet and script), get a new address when the theme or plugin version changes. A month or more is fine, as long as the version really changes when the file does. - Plain names such as
/css/style.csskeep the same address after an edit. With a long cache, returning visitors keep the old file. Use 7 days here, and add a version or a hash to the name when you can. - Images and fonts rarely change under the same name. 30 days to a year is normal.
Check it yourself
List the files the homepage loads, then look at the headers of one of them. Replace example.com and the path with your own:
curl -s https://example.com | grep -oE '(href|src)="[^"]+\.(css|js|png|jpe?g|webp|svg|woff2?)[^"]*"' | head -20
curl -sI https://example.com/wp-content/themes/example/style.css | grep -iE '^(cache-control|expires):'
Fixed means cache-control shows max-age=604800 or more (or immutable), with no no-cache or no-store. If the second command prints nothing, the file has no cache header at all.
How to fix
Cache headers are set by whatever serves the files: the web server, the hosting panel, a CDN or the app. Pick what you have access to; one place is enough.
WordPress
WordPress itself sends no cache headers for files. The server does, and a few caching plugins can set them for you.
- LiteSpeed Cache (on LiteSpeed hosting): LiteSpeed Cache → Cache → Browser, turn Browser Cache on and keep the default TTL (one year). Save, then Purge All.
- WP Rocket adds browser cache rules to
.htaccessby itself on Apache and LiteSpeed. There is no switch for it. On nginx it cannot write server rules: see the nginx tab or ask your host.
Without either plugin, add the .htaccess rules from the Apache and LiteSpeed tab. WordPress puts ?ver= on enqueued CSS and JavaScript, so a month is safe for those. If you edit your theme's CSS by hand without changing the theme version, visitors may see the old file for as long as the cache lasts: bump Version: in the theme's style.css after such edits.
cPanel
cPanel has no switch for browser caching. The rules go in the .htaccess file in the site's root folder:
- Open Files → File Manager and go to
public_html(or the add-on domain's folder). - Click Settings in the top right, tick Show Hidden Files (dotfiles) and save.
- Right-click
.htaccess→ Edit, paste the block from the Apache and LiteSpeed tab at the end, and save.
On LiteSpeed hosting with WordPress, the LiteSpeed Cache plugin can write the same rules for you (WordPress tab).
Plesk
In Plesk, nginx usually sits in front of Apache and serves static files itself, so the rule belongs in nginx. Go to Websites & Domains → example.com → Apache & nginx Settings and in Additional nginx directives add:
location ~* \.(?:css|js|mjs|png|jpe?g|gif|svg|webp|avif|ico|woff2?)$ {
expires 30d;
}
Click OK; Plesk checks the syntax and reloads nginx.
If Plesk reports a duplicate location, its Serve static files directly by nginx option already defines one. In that case turn that option off and use the .htaccess rules from the Apache and LiteSpeed tab instead.
nginx
Add the rules inside the site's server { … } block: on Debian and Ubuntu in /etc/nginx/sites-available/example.com, on Rocky, Alma and CentOS in /etc/nginx/conf.d/example.com.conf.
# Fingerprinted build output (Vite, webpack): a new version is a new file.
location /build/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
# Everything else with a plain name.
location ~* \.(?:css|js|mjs)$ {
expires 7d;
}
location ~* \.(?:png|jpe?g|gif|svg|webp|avif|ico|woff2?)$ {
expires 30d;
}
expires sends both Cache-Control: max-age=… and Expires. Change /build/ to the folder your build tool writes to.
Warning: An
add_headerinside alocationreplaces everyadd_headerfrom theserverblock for those files, including security headers such as HSTS. If you set headers inserver, repeat them in thislocationtoo.
Test and apply:
sudo nginx -t && sudo systemctl reload nginx
Apache and LiteSpeed
mod_expires sets both Cache-Control: max-age and Expires. Put this in the .htaccess in the site's root, or in the virtual host on your own server. LiteSpeed reads it the same way.
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType text/css "access plus 1 month"
ExpiresByType text/javascript "access plus 1 month"
ExpiresByType application/javascript "access plus 1 month"
ExpiresByType image/png "access plus 1 year"
ExpiresByType image/jpeg "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 font/woff2 "access plus 1 year"
</IfModule>
If your CSS and JavaScript have plain names without a version, use "access plus 1 week" for those two lines.
For fingerprinted files you can add immutable with mod_headers, for example in a .htaccess inside the build folder:
<IfModule mod_headers.c>
Header set Cache-Control "public, max-age=31536000, immutable"
</IfModule>
On your own server, enable the modules and reload (on Rocky, Alma and CentOS both are on by default; use httpd instead of apache2):
sudo a2enmod expires headers
sudo apachectl configtest && sudo systemctl reload apache2
Cloudflare
If the site runs behind Cloudflare (orange cloud), Cloudflare can raise the browser cache time for the files it caches (CSS, JavaScript, images and fonts by default):
- Open the domain and go to Caching → Configuration.
- Under Browser Cache TTL, the default Respect Existing Headers passes on whatever your server sends. Choose a value such as 1 month instead.
With a value set, Cloudflare uses it when your server sends a shorter time or none; a longer max-age from your server still wins. For different times per folder, use Rules → Cache Rules and set Browser TTL in the rule.
Node.js and Next.js
Next.js already serves /_next/static/… with public, max-age=31536000, immutable in production. Files in public/ get max-age=0, so images and fonts there are not cached. Set a header for them in next.config.js:
module.exports = {
async headers() {
return [
{
source: '/images/:path*',
headers: [{ key: 'Cache-Control', value: 'public, max-age=2592000' }],
},
];
},
};
Express with express.static:
// Fingerprinted build output: a year, immutable.
app.use('/assets', express.static('dist/assets', { maxAge: '1y', immutable: true }));
// Plain names: a week.
app.use(express.static('public', { maxAge: '7d' }));
If nginx serves the files in front of the app, set the headers there instead (nginx tab).
Laravel
Vite (npm run build) writes fingerprinted files to public/build/assets, such as app-3f2a9c1d.css, and @vite links them. They are safe to cache for a year. Laravel does not serve these files: the web server does, so the header goes there.
- nginx (Forge, Ploi, your own server): add the
location /build/block from the nginx tab to the site's configuration. - Apache: the default
public/.htaccesshas no cache rules. Add themod_expiresblock from the Apache and LiteSpeed tab, and theimmutablerule in a.htaccessinsidepublic/build.
Images and other files you put straight into public/ keep their names. 30 days is a good value for them.
IIS and ASP.NET
In the site's web.config:
<configuration>
<system.webServer>
<staticContent>
<clientCache cacheControlMode="UseMaxAge" cacheControlMaxAge="30.00:00:00" />
</staticContent>
</system.webServer>
</configuration>
30.00:00:00 is 30 days. For a folder of fingerprinted files, put a web.config with 365.00:00:00 in that folder. In ASP.NET Core, set the header in UseStaticFiles through StaticFileOptions.OnPrepareResponse instead.
Hosted platforms
Shopify, Wix, Squarespace and Webflow serve theme files and images from their own CDN with their own cache headers, usually from another domain, which the checklist does not check. If a file on your own domain is flagged there, it is one the platform controls, and there is nothing to change on your side.
After a change
- Purge the CDN or page cache after changing headers. A cached copy keeps the old headers until it expires.
- Edits to plain-named files stay invisible to returning visitors until their cache runs out. Before a big redesign, give the CSS a new name or version instead of shortening the cache.
Confirm the fix
Run the curl commands from Check it yourself again, then open the target in PulseKeeper and click Check now on the Checklist tab. The item turns OK within a few minutes instead of at the next daily run.