Skip to content

Browser Caching in .htaccess

Learn how to configure browser caching in Apache using mod_expires and Cache-Control headers to optimize performance, reduce bandwidth, and speed up page loads for returning visitors.

Every time a user visits your site, their browser downloads HTML, CSS, JavaScript, images, fonts, and other resources. Without caching, the browser downloads these same files on every single page load, even if nothing has changed. This wastes bandwidth, slows down the experience, and puts unnecessary load on your server.

Browser caching tells the browser to store downloaded resources locally and reuse them for a specified period. A returning visitor's page load can go from downloading 3MB of assets to downloading almost nothing, with the page appearing nearly instantly from the local cache.

This guide covers how browser caching works, how to configure it using mod_expires and Cache-Control headers, how ETags enable conditional requests, recommended cache durations for different resource types, and strategies for busting the cache when you deploy updates.

How Browser Caching Works

When a browser requests a resource, the server can include headers that tell the browser how long to keep a local copy. On subsequent requests for the same resource, the browser checks its cache:

  1. Cache hit (fresh): The cached copy is still within its validity period. The browser uses it directly without contacting the server. Zero network traffic.

  2. Cache hit (stale, revalidation): The cached copy has expired but might still be valid. The browser sends a conditional request to the server with an If-Modified-Since or If-None-Match header. If the resource has not changed, the server responds with 304 Not Modified (no body), and the browser uses its cached copy. Only a tiny amount of network traffic.

  3. Cache miss: No cached copy exists. The browser downloads the full resource.

First visit:
  Browser ──GET /style.css──▶ Server
  Browser ◀──200 OK + full file + cache headers──

Second visit (cache still fresh):
  Browser checks cache → "still valid" → uses cached copy
  (No network request at all)

After cache expires:
  Browser ──GET /style.css (If-None-Match: "abc123")──▶ Server
  Browser ◀──304 Not Modified (no body)──
  Browser uses cached copy

The key insight is that caching is not about avoiding downloads forever. It is about avoiding unnecessary downloads. The cache headers you set determine how long the browser trusts its local copy before checking with the server.

mod_expires

Apache's mod_expires module provides the simplest way to set cache expiration times. It generates both Expires and Cache-Control: max-age headers automatically, ensuring compatibility with all browsers and HTTP versions.

ExpiresActive

Before any expiration rules work, you must enable the module:

<IfModule mod_expires.c>
  ExpiresActive on
</IfModule>

Without ExpiresActive on, all ExpiresDefault and ExpiresByType directives are silently ignored. This is the most common reason caching "does not work" after configuration.

ExpiresDefault

ExpiresDefault sets a fallback expiration for any response that does not match a specific ExpiresByType rule:

<IfModule mod_expires.c>
  ExpiresActive on
  ExpiresDefault "access plus 1 month"
</IfModule>

The syntax "access plus 1 month" means "one month after the browser first downloaded the file." The time can be specified using combinations of:

Unit Example
seconds "access plus 30 seconds"
minutes "access plus 10 minutes"
hours "access plus 6 hours"
days "access plus 7 days"
weeks "access plus 2 weeks"
months "access plus 1 month"
years "access plus 1 year"

You can also combine units:

ExpiresDefault "access plus 1 month 2 weeks 3 days"

The keyword access means "from the time the browser first requests the file." The alternative modification means "from the time the file was last modified on the server," but access is almost always what you want.

ExpiresByType

ExpiresByType sets cache duration for specific media types, overriding ExpiresDefault:

<IfModule mod_expires.c>
  ExpiresActive on
  ExpiresDefault                              "access plus 1 month"

  ExpiresByType text/html                     "access plus 0 seconds"

  ExpiresByType text/css                      "access plus 1 year"
  ExpiresByType text/javascript               "access plus 1 year"

  ExpiresByType image/jpeg                    "access plus 1 month"
  ExpiresByType image/png                     "access plus 1 month"
  ExpiresByType image/webp                    "access plus 1 month"
  ExpiresByType image/svg+xml                 "access plus 1 month"

  ExpiresByType font/woff2                    "access plus 1 month"
  ExpiresByType font/woff                     "access plus 1 month"

  ExpiresByType application/json              "access plus 0 seconds"
  ExpiresByType application/xml               "access plus 0 seconds"
</IfModule>

Setting "access plus 0 seconds" does not mean "never cache." It means the cached copy expires immediately and must be revalidated with the server on every request. The browser still stores the file locally and can use conditional requests (304 Not Modified) to avoid re-downloading it if it has not changed.

Cache-Control Header

While mod_expires handles basic cache duration, the Cache-Control header provides much finer control over caching behavior. You set it using mod_headers:

<IfModule mod_headers.c>
  Header set Cache-Control "public, max-age=31536000"
</IfModule>

Cache-Control accepts multiple directives separated by commas. Here are the most important ones.

max-age

max-age specifies how many seconds the browser should consider the cached copy fresh:

Header set Cache-Control "max-age=31536000"

Header set Cache-Control "max-age=604800"

Header set Cache-Control "max-age=3600"

Common durations:

Duration Seconds Use Case
1 minute 60 Rapidly changing content
1 hour 3600 Frequently updated data
1 day 86400 Daily-updated content
1 week 604800 Favicons, manifests
1 month 2592000 Images, fonts
1 year 31536000 Versioned CSS, JS

public vs private

These directives control where the response can be cached:

Header set Cache-Control "public, max-age=31536000"

Header set Cache-Control "private, max-age=3600"
Directive Browser Cache CDN/Proxy Cache Use Case
public Yes Yes Static assets, public pages
private Yes No User-specific content, authenticated responses

Use private for any response that contains user-specific data (account pages, personalized content, responses with session data). Use public for static assets that are identical for all users.

no-cache vs no-store

These two directives are frequently confused. They do very different things:

no-cache does NOT mean "don't cache." It means "cache the response, but always revalidate with the server before using it":

Header set Cache-Control "no-cache"

The browser keeps the file in its cache. Before using it, it sends a conditional request to the server. If the file has not changed, the server responds with 304 (no body). This is efficient because only headers are transferred, not the full file.

no-store means "do not cache this response at all, ever":

Header set Cache-Control "no-store"

The browser does not keep the file. Every request downloads the full file from scratch. No conditional requests, no 304 responses, no local copies.

Directive Stored in Cache? Revalidation Full Download on Each Request Use Case
no-cache Yes Always Only if changed HTML pages, API responses
no-store No N/A Always Sensitive data, banking, health

immutable

The immutable directive tells the browser that the resource will never change during its max-age period. The browser should not even attempt to revalidate it, not even when the user explicitly hits the reload button:

Header set Cache-Control "public, max-age=31536000, immutable"

Without immutable, pressing the reload button causes the browser to send conditional requests for all resources, even if they are still fresh. With immutable, the browser skips revalidation entirely because you have guaranteed the content will not change.

This is perfect for versioned static assets where the filename changes with every update (e.g., style.a1b2c3.css). Since the URL changes when the content changes, the cached version never needs revalidation.

Applying Cache-Control to Specific File Types

Use <FilesMatch> to apply different policies to different file types:

Targeted Cache-Control headersapache
<IfModule mod_headers.c>
  <FilesMatch "\.(html|htm)$">
    Header set Cache-Control "no-cache"
  </FilesMatch>

  <FilesMatch "\.(css|js|mjs)$">
    Header set Cache-Control "public, max-age=31536000, immutable"
  </FilesMatch>

  <FilesMatch "\.(jpg|jpeg|png|gif|webp|avif|svg|ico)$">
    Header set Cache-Control "public, max-age=2592000"
  </FilesMatch>

  <FilesMatch "\.(woff2|woff|ttf|otf|eot)$">
    Header set Cache-Control "public, max-age=31536000, immutable"
  </FilesMatch>

  <FilesMatch "\.(json|xml)$">
    Header set Cache-Control "no-cache"
  </FilesMatch>
</IfModule>

ETags

ETags (Entity Tags) are unique identifiers that Apache assigns to each version of a file. They enable conditional requests, which are the mechanism behind efficient cache revalidation.

How ETags Work

When a browser first downloads a file, the server includes an ETag in the response:

HTTP/1.1 200 OK
ETag: "a1b2c3d4e5"
Content-Length: 45678
Cache-Control: no-cache

The browser stores both the file and its ETag. When the cache expires (or no-cache is set), the browser sends a conditional request:

GET /style.css HTTP/1.1
If-None-Match: "a1b2c3d4e5"

If the file has not changed, its ETag is still "a1b2c3d4e5", and the server responds:

HTTP/1.1 304 Not Modified
ETag: "a1b2c3d4e5"

No body is sent. The browser uses its cached copy. This saves the bandwidth of downloading the full file.

If the file has changed, its ETag is different, and the server responds with the full new file:

HTTP/1.1 200 OK
ETag: "f6g7h8i9j0"
Content-Length: 48901

FileETag Directive

The FileETag directive controls how Apache generates ETags. By default, Apache uses three components:

FileETag INode MTime Size
  • INode: The file's filesystem inode number.
  • MTime: The file's last modification time.
  • Size: The file's size in bytes.

The inode component creates problems in load-balanced environments where the same file exists on multiple servers with different inode numbers. The same file produces different ETags on different servers, defeating the caching benefit.

Recommended configuration:

FileETag MTime Size

This removes the inode component while keeping modification time and size, which are consistent across servers with identical file content.

When to Disable ETags

In some scenarios, you may want to disable ETags entirely:

<IfModule mod_headers.c>
  Header unset ETag
</IfModule>
FileETag None

Disable ETags when:

  • You rely entirely on Cache-Control: max-age and immutable for caching, and your files use versioned filenames.
  • You want to minimize response header size for performance.
  • Your load balancer setup makes consistent ETags difficult.

Keep ETags when:

  • Your HTML pages use no-cache and need efficient revalidation (ETags make 304 responses possible).
  • You serve dynamic content where modification times are not available.
  • You want the most efficient caching possible for non-versioned resources.

For most sites, keeping ETags with FileETag MTime Size is the best approach. It enables efficient revalidation while avoiding the inode consistency problem.

Different resource types have different update frequencies and should be cached for different durations. Here is a comprehensive configuration based on widely-accepted best practices:

HTML

HTML pages are the entry point to your site and should always be revalidated. If you cache HTML aggressively and then deploy a new version, users continue seeing the old page and loading the old CSS and JavaScript references.

ExpiresByType text/html "access plus 0 seconds"

CSS and JavaScript

If you use versioned filenames (e.g., style.a1b2c3.css, app.bundle.4d5e6f.js), cache these for the maximum duration. The filename changes when the content changes, so the old cached version is never served.

ExpiresByType text/css        "access plus 1 year"
ExpiresByType text/javascript "access plus 1 year"

If you do not use versioned filenames, use a shorter duration:

ExpiresByType text/css        "access plus 1 week"
ExpiresByType text/javascript "access plus 1 week"

Images

Images rarely change once published. A month is a good balance between caching efficiency and being able to update images when needed:

ExpiresByType image/jpeg    "access plus 1 month"
ExpiresByType image/png     "access plus 1 month"
ExpiresByType image/gif     "access plus 1 month"
ExpiresByType image/webp    "access plus 1 month"
ExpiresByType image/avif    "access plus 1 month"
ExpiresByType image/svg+xml "access plus 1 month"
ExpiresByType image/x-icon  "access plus 1 week"

Favicons get a shorter duration because they are often the only image file with a fixed, non-versioned filename.

Fonts

Fonts change extremely rarely. Cache them for a long period:

ExpiresByType font/woff2                    "access plus 1 year"
ExpiresByType font/woff                     "access plus 1 year"
ExpiresByType font/ttf                      "access plus 1 year"
ExpiresByType font/otf                      "access plus 1 year"
ExpiresByType font/collection               "access plus 1 year"
ExpiresByType application/vnd.ms-fontobject "access plus 1 year"

Media

Audio and video files are large and rarely modified:

ExpiresByType audio/ogg   "access plus 1 month"
ExpiresByType audio/mp4   "access plus 1 month"
ExpiresByType video/mp4   "access plus 1 month"
ExpiresByType video/webm  "access plus 1 month"
ExpiresByType video/ogg   "access plus 1 month"

Complete mod_expires Configuration

Comprehensive caching configurationapache
<IfModule mod_expires.c>
  ExpiresActive on
  ExpiresDefault                                      "access plus 1 month"

  ExpiresByType text/html                             "access plus 0 seconds"

  ExpiresByType text/css                              "access plus 1 year"

  ExpiresByType text/javascript                       "access plus 1 year"
  ExpiresByType application/javascript                "access plus 1 year"

  ExpiresByType application/atom+xml                  "access plus 1 hour"
  ExpiresByType application/rdf+xml                   "access plus 1 hour"
  ExpiresByType application/rss+xml                   "access plus 1 hour"
  ExpiresByType application/json                      "access plus 0 seconds"
  ExpiresByType application/ld+json                   "access plus 0 seconds"
  ExpiresByType application/schema+json               "access plus 0 seconds"
  ExpiresByType application/geo+json                  "access plus 0 seconds"
  ExpiresByType application/xml                       "access plus 0 seconds"
  ExpiresByType text/calendar                         "access plus 0 seconds"
  ExpiresByType text/xml                              "access plus 0 seconds"

  ExpiresByType image/vnd.microsoft.icon              "access plus 1 week"
  ExpiresByType image/x-icon                          "access plus 1 week"

  ExpiresByType image/bmp                             "access plus 1 month"
  ExpiresByType image/gif                             "access plus 1 month"
  ExpiresByType image/jpeg                            "access plus 1 month"
  ExpiresByType image/png                             "access plus 1 month"
  ExpiresByType image/apng                            "access plus 1 month"
  ExpiresByType image/svg+xml                         "access plus 1 month"
  ExpiresByType image/webp                            "access plus 1 month"
  ExpiresByType image/avif                            "access plus 1 month"
  ExpiresByType image/heic                            "access plus 1 month"
  ExpiresByType image/heif                            "access plus 1 month"

  ExpiresByType audio/ogg                             "access plus 1 month"
  ExpiresByType video/mp4                             "access plus 1 month"
  ExpiresByType video/ogg                             "access plus 1 month"
  ExpiresByType video/webm                            "access plus 1 month"

  ExpiresByType application/manifest+json             "access plus 1 week"
  ExpiresByType application/x-web-app-manifest+json   "access plus 0 seconds"
  ExpiresByType text/cache-manifest                   "access plus 0 seconds"

  ExpiresByType application/wasm                      "access plus 1 year"

  ExpiresByType font/collection                       "access plus 1 month"
  ExpiresByType font/eot                              "access plus 1 month"
  ExpiresByType font/opentype                         "access plus 1 month"
  ExpiresByType font/otf                              "access plus 1 month"
  ExpiresByType font/ttf                              "access plus 1 month"
  ExpiresByType font/woff                             "access plus 1 month"
  ExpiresByType font/woff2                            "access plus 1 month"
  ExpiresByType application/vnd.ms-fontobject         "access plus 1 month"
  ExpiresByType application/x-font-ttf                "access plus 1 month"

  ExpiresByType text/x-cross-domain-policy            "access plus 1 week"
  ExpiresByType text/markdown                         "access plus 0 seconds"
</IfModule>

Cache-Busting Strategies

Aggressive caching creates a problem: how do you get users to download the new version of a file when you deploy updates? If style.css is cached for one year, updating it on the server does nothing for users who already have the old version cached.

Cache busting is the practice of changing the URL of a resource when its content changes, forcing browsers to download the new version.

The most reliable approach is to include a content hash or version number in the filename:

<!-- Before update -->
<link rel="stylesheet" href="/css/style.a1b2c3d4.css">
<script src="/js/app.e5f6g7h8.js"></script>

<!-- After update -->
<link rel="stylesheet" href="/css/style.i9j0k1l2.css">
<script src="/js/app.m3n4o5p6.js"></script>

Because the filename changes, the browser treats it as a completely new resource and downloads it fresh. The old file remains in the cache but is never referenced again.

Build tools like Webpack, Vite, Parcel, and Gulp handle this automatically. They generate content hashes and update HTML references during the build process.

This is why you can safely cache CSS and JavaScript for one year with immutable:

<FilesMatch "\.(css|js)$">
  Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>

The year-long cache is never a problem because you never update the file in place. You create a new file with a new name.

Query String Versioning

An alternative is to append a version query string:

<link rel="stylesheet" href="/css/style.css?v=1.2.3">
<script src="/js/app.js?v=1.2.3"></script>

This works in most cases, but has a significant drawback: some CDNs and proxies ignore query strings when caching, meaning they may serve the old version despite the new query parameter. Filename versioning is always more reliable.

Common Mistake: Long Cache Without Cache Busting

Wrong approach:

ExpiresByType text/css "access plus 1 year"
<!-- No versioning in the filename -->
<link rel="stylesheet" href="/css/style.css">

When you update style.css, users who visited in the past year still have the old version cached. They see a broken layout because the new HTML references classes or structures that only exist in the updated CSS.

Correct approach:

Either use versioned filenames:

<link rel="stylesheet" href="/css/style.a1b2c3.css">

Or use a shorter cache duration if versioning is not possible:

ExpiresByType text/css "access plus 1 week"

Browser caching is one of the most impactful performance optimizations you can implement. A well-configured caching strategy means returning visitors load your pages almost instantly, your server handles more traffic with less load, and your bandwidth costs drop significantly. Configure mod_expires for broad coverage, fine-tune with Cache-Control headers for specific needs, keep ETags for efficient revalidation, and always pair aggressive caching with a cache-busting strategy so your users never see stale content after an update.

Navigation