cPanel Hosting

Browser Caching and Cache-Control Headers Explained

Setting Cache-Control and Expires headers so returning visitors don't redownload static assets - the .htaccess rules, the right values, and the common mistakes.

5 min read

When someone visits your site a second time, their browser shouldn’t need to redownload every CSS file, every image, every script. Browser caching tells the browser “this hasn’t changed since last visit, use your stored copy.” Set up correctly, repeat visitors see nearly instant page loads. Set up wrong (or not at all), every visit pays the full bandwidth cost. This guide covers the headers that control caching, the right values for different file types, and how to apply them via .htaccess.

How browser caching works

First visit: browser downloads everything and stores it. Server sends headers like Cache-Control: max-age=2592000 meaning “this file is good for 30 days.”

Second visit within 30 days: browser doesn’t even ask the server; just uses cached file. Sub-second page render.

After 30 days: browser asks server “do you have a newer version?” If not, server responds 304 Not Modified (tiny) and browser uses cached file.

The trick is picking cache durations that maximize cache hits while still letting you update content when needed.

The two main caching headers

Cache-Control (modern)

Cache-Control: max-age=2592000, public, immutable

Directives:

  • max-age=N — Cache this for N seconds.
  • public — Anyone can cache (browser, CDN, proxy).
  • private — Only the user’s browser can cache. Use for personalized content.
  • immutable — Promise content won’t change at this URL. Browser doesn’t even check.
  • no-cache — Must revalidate with server before using cache.
  • no-store — Never cache. Use for sensitive content.

Expires (older but still supported)

Expires: Thu, 31 Dec 2026 23:59:59 GMT

Absolute date when cached copy expires. Cache-Control wins if both are present.

File typeCache durationReasoning
Images (jpg, png, webp, gif, svg)1 yearFilenames change on update; long cache is safe
CSS, JS (versioned)1 yearIf you version filenames (style.v2.css), safe to cache forever
CSS, JS (unversioned)1 hour to 1 dayWithout versioning you need short cache or visitors see stale code
Fonts (woff, woff2)1 yearRarely change; cache aggressively
HTML pages0-15 minutesContent changes; short cache
JSON / API responsesVaries — 0 to a few minutesApplication-specific
Logged-in / personalizedprivate, no-cacheNever share user data across browsers

Apache .htaccess implementation

Add to your .htaccess at site root:

<IfModule mod_expires.c>
    ExpiresActive On

    # HTML - short cache
    ExpiresByType text/html "access plus 0 seconds"

    # CSS & JS - 1 year (assuming versioned)
    ExpiresByType text/css "access plus 1 year"
    ExpiresByType application/javascript "access plus 1 year"
    ExpiresByType text/javascript "access plus 1 year"

    # Images - 1 year
    ExpiresByType image/jpeg "access plus 1 year"
    ExpiresByType image/png "access plus 1 year"
    ExpiresByType image/webp "access plus 1 year"
    ExpiresByType image/avif "access plus 1 year"
    ExpiresByType image/gif "access plus 1 year"
    ExpiresByType image/svg+xml "access plus 1 year"

    # Fonts - 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"

    # Favicon - 1 week
    ExpiresByType image/x-icon "access plus 1 week"

    # PDF documents - 1 month
    ExpiresByType application/pdf "access plus 1 month"
</IfModule>

# Set Cache-Control header for static assets
<IfModule mod_headers.c>
    <FilesMatch ".(jpg|jpeg|png|gif|webp|avif|svg|ico|css|js|woff|woff2|ttf|eot)$">
        Header set Cache-Control "public, max-age=31536000, immutable"
    </FilesMatch>

    <FilesMatch ".(html|htm)$">
        Header set Cache-Control "public, max-age=0, must-revalidate"
    </FilesMatch>
</IfModule>

Apply this and reload. New visitors get cached responses appropriately.

The cache-busting problem

If style.css is cached for 1 year and you update it, visitors won’t see the change for a year. Bad.

Solution: version your filenames or query strings.

Filename versioning

<link rel="stylesheet" href="style.20260530.css">

When you update CSS, rename to style.20260601.css and update the HTML reference. Browser sees new URL, downloads new file.

Query string versioning

<link rel="stylesheet" href="style.css?v=20260530">

Same effect. Some CDNs handle this differently; filename versioning is more universally reliable.

WordPress handles this automatically

WordPress adds version query strings to enqueued CSS/JS automatically. When you update a plugin or theme, the version bumps; browser sees a new URL and refetches. You can cache aggressively without worrying about stale assets.

Verifying cache headers

  1. Browser DevTools → Network tab.
  2. Reload page.
  3. Click any image or asset in the list.
  4. Headers tab → Response Headers.
  5. Look for Cache-Control. Should match your .htaccess settings.

Common gotcha: WordPress sets its own headers via plugins/server. If your .htaccess values don’t appear, something is overriding them. Check LiteSpeed Cache plugin settings, WP Rocket, or similar.

Common pitfalls

1. Caching HTML pages aggressively. Visitors don’t see content updates. Keep HTML cache short (0-15 min).

2. Different cache settings on CDN vs origin. If you use Cloudflare, set caching at Cloudflare level too. Check Cloudflare → Caching → Configuration.

3. Caching personalized content publicly. A logged-in user’s cart shouldn’t be cached and shown to other visitors. Use Cache-Control: private for personalized content.

4. Forgetting fonts. Custom fonts cached without proper headers slow repeat page loads. Add font extensions to your caching rules.

5. .htaccess works but LiteSpeed users wonder why. LiteSpeed honors .htaccess including the mod_headers and mod_expires directives.

CDN integration

A CDN sits between visitors and your server. Cache headers tell BOTH the CDN and the visitor’s browser how long to cache. Set them once at your origin server; CDN respects them.

Cloudflare specifically:

  • By default respects your Cache-Control headers.
  • Cloudflare → Caching → Browser TTL — fallback when your origin doesn’t set headers.
  • Page Rules can override for specific URL patterns.

When to lower caching

  • Active development — Short cache so you see changes immediately. Reset to long cache when launching.
  • Time-sensitive content — Live scores, stock prices, news headlines.
  • Logged-in dashboards — Always private, no-cache.

Common questions

“My updates aren’t showing for visitors after edits.” Browser cache. Tell visitors to hard-refresh (Ctrl+Shift+R). Long-term: version your asset URLs or shorten cache.

“Cache-Control: public vs private — which?” Public for images/CSS/JS (shareable). Private for any user-specific data.

“How do I clear visitors’ caches when I push a major update?” Hard to force. Best you can do: change filenames/version strings so old cached files don’t match. Visitors get fresh.

“PageSpeed says ‘serve static assets with efficient cache policy’.” Add max-age of 1+ year for static assets (images, CSS, JS, fonts). Use .htaccess rules above.

“My API endpoints shouldn’t be cached at all.” Set Cache-Control: no-store on those routes. In WordPress REST, this is default for most endpoints.

What’s next

Browser caching is set-and-forget once configured correctly. The .htaccess block above works for nearly every site; copy, save, verify with DevTools. Returning visitors notice; PageSpeed scores improve immediately; bandwidth usage drops. Five minutes of work for permanent gains.

Was this helpful?