If Google Lighthouse just hit you with “Serve static assets with an efficient cache policy”, your server is handing out images, scripts, and stylesheets without an expiration date. Here’s how to drop a tested .htaccess snippet into Hostinger hPanel to pin down Cache-Control and Expires headers across your entire static stack.
Whenever a browser loads your page, it pulls dozens of static files: web fonts, logos, compiled CSS bundles, JavaScript vendors. If LiteSpeed doesn’t tell the browser how long it’s allowed to stash those files locally, the browser re-downloads every single asset on every pageview. That wrecks your load times and drags down your Core Web Vitals score.

Diagnosing the Lighthouse Cache Warning on Hostinger
Lighthouse triggers this warning whenever a static resource has a time-to-live (TTL) under 30 days or skips cache headers altogether. Chrome DevTools will hand you a list of offending URLs, their current TTL, and how many kilobytes you just wasted.
Hostinger runs LiteSpeed Web Server (LSWS) across both shared and cloud tiers. LiteSpeed parses Apache .htaccess directives out of the box, but fresh installs often ship with empty caching rules for static media or lean entirely on dynamic WordPress plugins. When that happens, assets like /wp-content/uploads/photo.webp get served with a bare Cache-Control: no-transform header or a measly 4-hour max-age.
Here’s what I got when probing an unconfigured Hostinger domain straight from the terminal:
curl -I https://example.com/wp-content/themes/custom/assets/main.cssThe response tells you everything you need to know:
HTTP/2 200
content-type: text/css; charset=UTF-8
server: LiteSpeed
platform-cache: HIT
etag: "318a-660c1d-65b12"
last-modified: Mon, 15 Jan 2024 10:14:02 GMTNo cache-control: max-age=... and zero expires header. According to the Lighthouse cache policy documentation, any static file without at least a 30-day cache window fails the audit instantly.
How Hostinger and LiteSpeed Read .htaccess
Hostinger uses LiteSpeed Enterprise under the hood. LiteSpeed reads Apache rules in .htaccess natively, including directives for mod_expires and mod_headers.
You don’t need to rewrite anything into Nginx syntax or mess with reverse-proxy configurations. That said, LiteSpeed parses rules top-to-bottom and caches the resulting config in memory. Throw a typo or bad directive in there, and you’ll trigger an immediate 500 Internal Server Error site-wide.
We’re going to target two core modules:
- mod_expires: Generates the absolute
Expiresdate header for HTTP/1.0 and maps directly tomax-agein HTTP/1.1. - mod_headers: Lets us customize
Cache-Controlflags directly so we can attachpublicandimmutable.
If you recently switched to an SSL certificate or migrated hosting, keep an eye out for protocol mismatches too. Check our guide on how to resolve WordPress mixed content with LiteSpeed and .htaccess on Hostinger if you’re battling mixed content warnings alongside caching errors.
Step 1: Locating and Backing Up .htaccess in hPanel
Always clone your config before touching production. A stray character can drop your site in seconds, and having a backup means you recover instantly instead of troubleshooting under panic.
- Log in to Hostinger and head into hPanel.
- Click Websites, select your domain, and click Manage.
- In the left sidebar under Files, open File Manager.
- Go into the public_html directory.
- Find
.htaccess. If it isn’t visible, click the settings gear in the top-right toolbar and enable Show Hidden Files (dotfiles). - Right-click
.htaccess, select Copy, and name it.htaccess.backup.
If you’re already connected over SSH, just duplicate the file from your terminal:
cd ~/public_html
cp .htaccess .htaccess.backup-$(date +%F)With your fallback saved, we can add the production cache rules.
Step 2: The Production .htaccess Block for Static Caching
Open .htaccess inside the hPanel editor. Place this block at the very top or bottom of the file. Whatever you do, don’t place it inside the # BEGIN WordPress and # END WordPress markers—core WordPress updates and permalink flushes routinely overwrite that block.
Paste this full configuration block above # BEGIN WordPress:
# ----------------------------------------------------------------------
# BROWSER CACHING HEADERS (LITESPEED / HOSTINGER)
# ---------------------------------------------------------------------- ExpiresActive On ExpiresDefault "access plus 1 month" # Media files (Images & Audio/Video) ExpiresByType image/jpeg "access plus 1 year" ExpiresByType image/jpg "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" # Web Fonts ExpiresByType font/ttf "access plus 1 year" ExpiresByType font/otf "access plus 1 year" ExpiresByType font/woff "access plus 1 year" ExpiresByType font/woff2 "access plus 1 year" ExpiresByType application/font-woff "access plus 1 year" ExpiresByType application/font-woff2 "access plus 1 year" ExpiresByType application/vnd.ms-fontobject "access plus 1 year" # Stylesheets and Scripts ExpiresByType text/css "access plus 1 year" ExpiresByType text/javascript "access plus 1 year" ExpiresByType application/javascript "access plus 1 year" ExpiresByType application/x-javascript "access plus 1 year" # Data and Documents ExpiresByType application/pdf "access plus 1 month" ExpiresByType text/html "access plus 0 seconds"
# Set explicit Cache-Control headers
# 1 Year (31536000 seconds) for truly static media & fonts Header set Cache-Control "max-age=31536000, public, immutable" # 1 Year for CSS and JavaScript Header set Cache-Control "max-age=31536000, public" # Prevent caching of dynamic documents Header set Cache-Control "max-age=0, private, no-cache, no-store, must-revalidate"
This tells browsers to cache images, fonts, CSS, and JS for an entire year (31,536,000 seconds). We also mark images and fonts with immutable. As noted in MDN’s Cache-Control reference, the immutable attribute stops modern browsers from firing unnecessary conditional validation requests (like If-None-Match) when a user refreshes.
Meanwhile, HTML documents explicitly receive max-age=0, no-cache, so your visitors always pull the freshest markup and newest posts immediately.
Step 3: Handling Font Assets and CORS Headers
If you serve custom fonts (like .woff2 or .woff) from subdirectories or off a CDN, caching them without explicit cross-origin permissions will blow up in the browser console. Chromium engines will outright block font rendering if the Access-Control-Allow-Origin header is missing.
Drop this right below your caching block so fonts cache cleanly without CORS issues:
Header set Access-Control-Allow-Origin "*"
If you’re piping traffic through a CDN, read through our guide on how to fix CORS font errors behind StackPath CDN to prevent edge servers from stripping those headers off origin requests.
Testing Your Cache Headers with cURL and DevTools
Save the file in hPanel. Don’t just refresh the page in your browser to check—local disk caches will fool you with cached headers. Hit the server directly with cURL.
Run curl -I against an actual asset on your site:
curl -I https://example.com/wp-content/themes/mytheme/style.cssYou should see headers formatted like this:
HTTP/2 200
content-type: text/css; charset=UTF-8
server: LiteSpeed
cache-control: max-age=31536000, public
expires: Fri, 06 Mar 2026 14:22:18 GMT
etag: "2b41-65f2c-5df18"
last-modified: Thu, 06 Mar 2025 14:22:18 GMTCheck for the two flags Lighthouse cares about:
cache-control: max-age=31536000, publicexpires: [Date exactly 1 year out]
Next, probe an image to confirm the immutable tag is sticking:
curl -I https://example.com/wp-content/uploads/2024/01/hero-banner.webpYou want to see cache-control: max-age=31536000, public, immutable. Once both return the 1-year max-age, run another test in PageSpeed Insights. The warning will be gone.
If you’re running headless frontends or single-page apps rather than basic WordPress, our walkthrough on how to deploy a React SPA on Hostinger with StackPath CDN covers how static bundles rely on this same 1-year policy.
The Gotcha: LiteSpeed Cache Plugin vs Manual .htaccess Rules
The first time I deployed this setup on a client’s staging site, my cURL tests failed. The custom .htaccess directives did nothing for .js and .css files. The headers kept returning cache-control: max-age=604800 (just 7 days).
The culprit was the LiteSpeed Cache (LSCache) WordPress plugin. The plugin has an internal browser cache controller. When turned on, it injects its own mod_expires rules further down the file and silently overrides your manual directives.
Here’s how to fix that clash:
- In your WordPress admin dashboard, go to LiteSpeed Cache > Cache.
- Open the [5] Browser tab.
- Set Browser Cache to ON.
- Change the Browser Cache TTL to
31536000seconds (1 year). - Save changes.
Once enabled inside LSCache, the plugin writes its own standardized block directly to the top of .htaccess:
# BEGIN Litespeed no-cache
# END Litespeed no-cache # BEGIN LSCACHE
## LITESPEED WP CACHE EXPIRES INCLUDECSS:on
## LITESPEED WP CACHE EXPIRES INCLUDESCRIPT:on
## LITESPEED WP CACHE EXPIRES INCLUDEIMAGES:on
# END LSCACHEIf you’re relying on the plugin’s built-in toggle, you don’t need duplicate mod_expires rules. But keep in mind: LSCache doesn’t append immutable to fonts and media. Leaving our manual block in .htaccess gives you the best of both worlds: clean plugin management plus the speed boost of immutable.
If you’re working in a staging environment, watch out for redirects wiping headers during checks. Look at our guide on how to fix WordPress staging sites redirecting to live domains on Hostinger.
Cache Invalidation: Safely Updating Versioned Assets
Telling browsers to hold files for 365 days has an obvious catch: if you change a button’s background color in style.css tomorrow, repeat visitors won’t see it until next year—unless you bust the cache.
Here are the two ways to handle cache invalidation properly:
1. Query String Versioning (Standard WordPress)
WordPress handles this natively if you enqueue styles and scripts through wp_enqueue_style() and wp_enqueue_script():
Browsers treat /style.css?ver=1.0.4 as a completely new asset compared to /style.css?ver=1.0.3, skipping the local disk cache and pulling the latest version immediately.
2. Filename Hashing (Vite, Webpack, Modern Frontends)
If you build your frontend with tools like Vite or Webpack, the bundler appends an asset hash straight into the file name (like app.7b91af.js). Because the filename changes whenever the code changes, long-term caching with immutable is completely safe.
If you run a proxy caching layer in front of Hostinger, make sure authenticated sessions don’t get trapped. Check our guide on how to bypass CDN cache for logged-in WordPress users so your admin dashboard and editorial updates show up without needing constant manual purges.
Frequently Asked Questions
Why does Lighthouse still warn about third-party scripts like Google Analytics?
You can’t control response headers for files hosted on external servers like googletagmanager.com, connect.facebook.net, or Google Fonts. The origin server decides the TTL. To silence those audits, host the scripts locally using tools like FlyingPress or CAOS, or offload analytics tracking to server-side containers.
Will setting a 1-year cache policy break my site when I push updates?
Not if your assets are versioned properly. Whenever you update theme CSS or JS, bump the version string in wp_enqueue_style() or flush your LiteSpeed cache. Media files like hero.jpg rarely change in-place; if you swap an image, upload it with a fresh filename.
Does LiteSpeed require a server restart after editing .htaccess?
No. LiteSpeed monitors .htaccess files and applies updates immediately. The second you save the file in hPanel, your new rules are live. If headers don’t reflect your changes right away, clear the LiteSpeed Page and Object Cache via the WordPress admin toolbar.
What is the difference between mod_expires and mod_headers?
mod_expires is an Apache module that calculates explicit expiration timestamps (Expires: Thu, 01 Jan 2026...) and automatically sets matching Cache-Control: max-age values. mod_headers gives you direct control over header strings, which you need for modern attributes like immutable, no-transform, or stale-while-revalidate.
Once your headers show max-age=31536000, re-test your site in PageSpeed Insights. The audit will pass, and repeat visitors will load your pages noticeably faster.

