Browser Caching with .htaccess and Nginx: A Practical Guide

browser caching
.htaccess
nginx
performance optimization
web performance

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 ETag or Last‑Modified) and receives a 304 Not Modified if the file hasn’t changed.
  • Cache has expired – the browser downloads a fresh copy.

The key headers are:

HeaderWhat it does
Cache-ControlModern, flexible directive (e.g., max-age=31536000 for one year).
ExpiresLegacy absolute date; still useful for very old clients.
ETagAllows efficient revalidation without sending the whole file.
PragmaMostly 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 all ExpiresByType lines.
  • Using overly long expiry times on non‑versioned files (e.g., style.css without 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 both Expires and Cache-Control: max-age in one line.
  • add_header – appends extra Cache-Control flags such as public, immutable, or must-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

FlagPurpose
expires 1y;Sets max-age=31536000 (one year) and an Expires header one year in the future.
add_header … immutableTells browsers not to revalidate the resource during its freshness lifetime.
alwaysEnsures 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:

  1. 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.
  2. 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:

PluginApache .htaccessNginx supportNotes
WP RocketWrites 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 CacheGenerates .htaccess rules via its “Browser Cache” module.Provides an Nginx configuration generator in the dashboard.Free with paid extensions; steeper learning curve.
WP Super CacheFocuses 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) and 304 (revalidated) or “(from disk cache)” on reloads.
  • Headers panel – verify Cache-Control and Expires values 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

MistakeSymptomFix
Caching non‑versioned CSS/JS for a yearAfter 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_headerHeaders 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-ageSite shows outdated content after a content update.Set HTML to expires 0 or Cache-Control: no-cache.
Not clearing caches when testingYou 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., Accept for WebP/AVIF), add add_header Vary "Accept" so caches store separate variants.
  • Logging Suppression – For high‑traffic sites, turning off access_log for static assets can shave milliseconds off each request and reduce disk wear.
  • Pre‑compressed Assets – If you build .gz or .br versions of your CSS/JS, enable gzip_static on; or brotli_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:

  1. Set long expiries for assets that change only when their filename changes (use hashes or version numbers).
  2. Keep HTML fresh with no-cache or a very short expiry.
  3. Add immutable when you can guarantee the URL won’t change for the lifetime of the cache.
  4. Verify with curl, browser dev tools, and online speed testers.
  5. 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!

Share this post:
Browser Caching with .htaccess and Nginx: A Practical Guide