Browser Caching with .htaccess and Nginx: A Practical Guide
Browser caching is one of the simplest yet most effective ways to make a website feel faster for returning visitors. By telling browsers to keep static files—images, CSS, JavaScript, fonts—stored locally, you cut down on repeat downloads, save bandwidth, and improve Core Web Vitals scores. The technique works the same whether your site runs on Apache (using a .htaccess file) or Nginx (using server‑level configuration). Below you’ll find a clear, step‑by‑step explanation of how caching works, the exact directives you need for each platform, and tips for testing and maintaining your setup.
How Browser Caching Works
When a browser requests a resource, the server can attach HTTP headers that describe how long that resource may be reused. On the first visit the browser downloads the file and stores it together with those headers. On subsequent visits it checks the stored headers:
- Cache is fresh – the file is used instantly, no network request.
- Cache is stale but still valid – the browser sends a conditional request (using
ETagorLast‑Modified) and receives a304 Not Modifiedif the file hasn’t changed. - Cache has expired – the browser downloads a fresh copy.
The key headers are:
| Header | What it does |
|---|---|
Cache-Control | Modern, flexible directive (e.g., max-age=31536000 for one year). |
Expires | Legacy absolute date; still useful for very old clients. |
ETag | Allows efficient revalidation without sending the whole file. |
Pragma | Mostly obsolete; kept for backward compatibility. |
By setting appropriate Cache-Control and max-age="Cache-public">Cache-and-max-public, you enable the browser to serve most assets from its local cache. The challenge is ensuring that when you actually update a file, users receive the new version—a problem solved with cache‑busting techniques (covered later).
Apache: Using .htaccess for Browser Caching
If your host runs Apache, you can control caching by editing the .htaccess file in your site’s root directory. The file is processed on every request, so changes take effect immediately (no server restart needed). The most common approach relies on the mod_expires and mod_headers modules.
Step‑by‑step: Enable the Modules
Before adding any rules, confirm that the required modules are active:
# On Debian/Ubuntu
sudo a2enmod expires headers
sudo systemctl restart apache2
On shared hosting the modules are usually already enabled; if you’re unsure, a quick phpinfo() page will show them under “Loaded Modules”.
Basic .htaccess Snippet
Place the following block near the top of your .htaccess file. It sets sensible expiration times for the most common asset types and adds a Cache-Control header with the public flag (allowing browsers and CDNs to cache).
# Begin Browser Caching
<IfModule mod_expires.c>
ExpiresActive On
# Images: one year
ExpiresByType image/jpeg "access plus 1 year"
ExpiresByType image/png "access plus 1 year"
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"
# CSS & JavaScript: one year (if versioned)
ExpiresByType text/css "access plus 1 year"
ExpiresByType application/javascript "access plus 1 year"
ExpiresByType text/javascript "access plus 1 year"
# Web fonts: one year
ExpiresByType font/woff "access plus 1 year"
ExpiresByType font/woff2 "access plus 1 year"
ExpiresByType font/ttf "access plus 1 year"
ExpiresByType font/otf "access plus 1 year"
# HTML: always revalidate
ExpiresByType text/html "access plus 0 seconds"
# Fallback for everything else
ExpiresDefault "access plus 1 month"
</IfModule>
<IfModule mod_headers.c>
# Ensure proper caching headers for static assets
<FilesMatch "\.(jpe?g|png|gif|webp|avif|svg|ico|css|js|woff2?|ttf|otf)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
# HTML gets no‑cache but allows conditional requests
<FilesMatch "\.(html|htm)$">
Header set Cache-Control "no-cache"
</FilesMatch>
</IfModule>
# End Browser Caching
Why immutable? When you pair a long max-age with immutable, the browser knows the file will never change for the duration of the cache and will skip revalidation entirely—saving even the tiny conditional request overhead. Use immutable only when you are certain the URL changes whenever the file content changes (see the cache‑busting section below).
Common Pitfalls
- Forgetting to turn
ExpiresActive On– the module ignores allExpiresByTypelines. - Using overly long expiry times on non‑versioned files (e.g.,
style.csswithout a hash) – returning visitors will see stale assets after you deploy updates. - Neglecting to clear the browser cache when testing – always open an incognito window or do a hard refresh (
Ctrl+Shift+R).
Nginx: Server‑Level Caching Directives
Nginx does not read .htaccess files; all configuration lives in the server block (usually /etc/nginx/sites‑available/yourdomain.com or a similar path). The advantage is that Nginx processes the rules once at startup, making them slightly more efficient under high load.
Core Directives
expires– sets bothExpiresandCache-Control: max-agein one line.add_header– appends extraCache-Controlflags such aspublic,immutable, ormust-revalidate.access_log off– disables logging for static assets, reducing disk I/O on busy sites.
Example Configuration
Below is a ready‑to‑paste block that you can drop inside the server { … } section of your site’s Nginx config. It mirrors the .htaccess example but uses Nginx syntax.
# Begin Browser Caching (Nginx)
# Images, CSS, JS, fonts – long term with immutable flag
location ~* \.(jpe?g|png|gif|webp|avif|svg|ico|css|js|woff2?|ttf|otf)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable" always;
access_log off;
log_not_found off;
}
# HTML – always revalidate but allow 304 responses
location ~* \.(html|htm)$ {
expires 0;
add_header Cache-Control "no-cache" always;
access_log off;
}
# Fallback for other static assets (e.g., pdf, txt, xml)
location ~* \.(pdf|txt|xml|json)$ {
expires 1M;
add_header Cache-Control "public" always;
access_log off;
}
# Everything else (dynamic content) – no caching
location / {
try_files $uri $uri/ /index.php?$args;
add_header Cache-Control "no-store" always;
}
# End Browser Caching
Explanation of the flags
| Flag | Purpose |
|---|---|
expires 1y; | Sets max-age=31536000 (one year) and an Expires header one year in the future. |
add_header … immutable | Tells browsers not to revalidate the resource during its freshness lifetime. |
always | Ensures the header is added even on error responses (e.g., 404). |
access_log off; | Stops Nginx from writing a log line for each static request, saving I/O. |
Cache Busting in Nginx
Long‑term caching only works safely when the URL changes whenever the file content changes. Two popular strategies are:
- Filename hashing – your build tool (Webpack, Vite, Rollup, etc.) outputs files like
app.a3f2c9.css. The HTML references that exact name; when the file changes, the hash changes, giving a new URL. - Versioned query strings –
<link rel="stylesheet" href="style.css?v=2.0">. Simpler to set up, but some CDNs ignore query strings, making it less reliable.
If you use filename hashing, the Nginx block above works unchanged because the regex matches the file extension regardless of the preceding hash. If you can strings location that strips the version before serving the file, but most modern setups avoid that extra complexity.
Verifying Your Nginx Setup
After editing the config, test for syntax errors and reload:
sudo nginx -t # Should return “syntax is ok” and “test is successful”
sudo systemctl reload nginx # Or: sudo nginx -s reload
Then inspect the headers with curl:
curl -I https://example.com/styles.a1b2c3.css
You should see something like:
HTTP/1.1 200 OK
Cache-Control: public, max-age=31536000, immutable
Expires: Fri, 18 Jan 2027 12:00:00 GMT
For HTML:
curl -I https://example.com/
Expect:
Cache-Control: no-cache
Finally, open Chrome DevTools → Network tab, reload the page, and check the “Size” column for static assets; they should read “(from disk cache)” or “(from memory cache)” on subsequent loads.
WordPress Plugins That Handle Browser Caching
If you prefer not to edit server files directly, several popular plugins take care of the headers for you:
| Plugin | Apache .htaccess | Nginx support | Notes |
|---|---|---|---|
| WP Rocket | Writes optimal caching rules to .htaccess on activation. | Requires manual Nginx snippet (similar to the one above). | Premium; includes minification, lazy loading, and CDN integration. |
| W3 Total Cache | Generates .htaccess rules via its “Browser Cache” module. | Provides an Nginx configuration generator in the dashboard. | Free with paid extensions; steeper learning curve. |
| WP Super Cache | Focuses on page caching; browser caching is a secondary feature. | No native Nginx browser‑caching UI; you must add rules manually. | Good for simple sites. |
| Leverage Browser Caching (free) | Zero‑config; writes .htaccess rules on activation. | Does not work on Nginx – you must add the Nginx block manually. | Ideal for small Apache sites that want a set‑and‑forget solution. |
When using a plugin, always verify that the generated rules match the recommendations above (long expiries for versioned assets, no-cache for HTML, etc.). After activation, run a quick test with GTmetrix or PageSpeed Insights to confirm the “Leverage browser caching” warning disappears.
Testing and Monitoring
Browser Developer Tools
- Network tab – look for
200(first load) and304(revalidated) or “(from disk cache)” on reloads. - Headers panel – verify
Cache-ControlandExpiresvalues match your config.
Online Speed Testers
- Google PageSpeed Insights – highlights missing caching headers.
- GTmetrix – shows a detailed waterfall and a “Leverage browser caching” score.
- WebPageTest – lets you inspect request/response headers for each asset.
Command‑Line Checks
# Quick header check for any asset
curl -I https://example.com/path/to/asset.jpg
# Verify Nginx config syntax
sudo nginx -t
# Reload after changes
sudo systemctl reload nginx
Common Mistakes and How to Avoid Them
| Mistake | Symptom | Fix |
|---|---|---|
| Caching non‑versioned CSS/JS for a year | After deploying a new stylesheet, visitors still see the old version. | Use filename hashing or query‑string versioning; otherwise, limit CSS/JS expiry to 1 week–1 month. |
Forgetting always on add_header | Headers appear only on 200 responses, missing on 304 or error pages. | Add always flag so headers are sent on every response. |
Over‑caching HTML with a long max-age | Site shows outdated content after a content update. | Set HTML to expires 0 or Cache-Control: no-cache. |
| Not clearing caches when testing | You think changes aren’t working because your browser still serves old files. | Use incognito mode, hard refresh (Ctrl+Shift+R), or clear browser cache manually. |
| Missing MIME types for newer formats (WebP, AVIF) | Nginx returns 404 or serves the file as application/octet-stream. | Ensure /etc/nginx/mime.types contains image/webp webp; and image/avif avif;. Reload Nginx after editing. |
Advanced Tweaks (Optional)
stale-while-revalidate– Allows the browser to serve a stale copy while quietly fetching a fresh one in the background. Example:Cache-Control: public, max-age=31536000, immutable, stale-while-revalidate=86400.- Vary Header – When you serve different content based on request headers (e.g.,
Acceptfor WebP/AVIF), addadd_header Vary "Accept"so caches store separate variants. - Logging Suppression – For high‑traffic sites, turning off
access_logfor static assets can shave milliseconds off each request and reduce disk wear. - Pre‑compressed Assets – If you build
.gzor.brversions of your CSS/JS, enablegzip_static on;orbrotli_static on;in Nginx to serve them without on‑the‑fly compression.
Wrapping Up
Browser caching is a low‑effort, high‑impact optimization that delivers faster repeat visits, lower server bandwidth, and better scores on tools like Google PageSpeed Insights. Whether you edit an .htaccess file on Apache or add a location block in Nginx, the principles are the same:
- Set long expiries for assets that change only when their filename changes (use hashes or version numbers).
- Keep HTML fresh with
no-cacheor a very short expiry. - Add
immutablewhen you can guarantee the URL won’t change for the lifetime of the cache. - Verify with
curl, browser dev tools, and online speed testers. - Maintain by revisiting your caching rules whenever you change your build pipeline or add new asset types.
Follow the snippets above, adapt the expiration times to your update frequency, and pair the setup with a solid cache‑busting strategy. Your visitors will enjoy near‑instant page loads on return visits, and you’ll see measurable improvements in both user experience and search‑engine performance. Happy caching!
