Browser Caching in .htaccess
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:
-
Cache hit (fresh): The cached copy is still within its validity period. The browser uses it directly without contacting the server. Zero network traffic.
-
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-SinceorIf-None-Matchheader. 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. -
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"
# HTML: Don't cache (always revalidate)
ExpiresByType text/html "access plus 0 seconds"
# CSS and JavaScript: Cache for 1 year
ExpiresByType text/css "access plus 1 year"
ExpiresByType text/javascript "access plus 1 year"
# Images: Cache for 1 month
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"
# Fonts: Cache for 1 month
ExpiresByType font/woff2 "access plus 1 month"
ExpiresByType font/woff "access plus 1 month"
# JSON and XML: Don't cache (dynamic data)
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.
When mod_expires generates headers, it produces both an Expires header (absolute timestamp) and a Cache-Control: max-age header (relative seconds). The Cache-Control header takes precedence in all modern browsers. The Expires header exists for compatibility with very old HTTP/1.0 clients.
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:
# Cache for 1 year (31536000 seconds)
Header set Cache-Control "max-age=31536000"
# Cache for 1 week (604800 seconds)
Header set Cache-Control "max-age=604800"
# Cache for 1 hour (3600 seconds)
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:
# Can be cached by browsers AND shared caches (CDNs, proxies)
Header set Cache-Control "public, max-age=31536000"
# Can ONLY be cached by the user's browser, NOT by shared caches
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":
# Browser stores the file but checks with server before every use
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":
# Browser must not store any part of the response
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 |
Use no-store only for genuinely sensitive content (banking transactions, medical records, etc.). Using it for regular HTML pages forces full downloads on every request, which degrades performance unnecessarily. For most HTML pages, no-cache is the correct choice because it allows efficient conditional requests.
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:
<IfModule mod_headers.c>
# HTML: Always revalidate
<FilesMatch "\.(html|htm)$">
Header set Cache-Control "no-cache"
</FilesMatch>
# CSS and JS: Cache aggressively (with versioned filenames)
<FilesMatch "\.(css|js|mjs)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
# Images: Cache for 1 month
<FilesMatch "\.(jpg|jpeg|png|gif|webp|avif|svg|ico)$">
Header set Cache-Control "public, max-age=2592000"
</FilesMatch>
# Fonts: Cache for 1 year
<FilesMatch "\.(woff2|woff|ttf|otf|eot)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
# JSON and API: No caching
<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-ageandimmutablefor 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-cacheand 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.
Recommended Cache Durations
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
<IfModule mod_expires.c>
ExpiresActive on
ExpiresDefault "access plus 1 month"
# HTML
ExpiresByType text/html "access plus 0 seconds"
# CSS
ExpiresByType text/css "access plus 1 year"
# JavaScript
ExpiresByType text/javascript "access plus 1 year"
ExpiresByType application/javascript "access plus 1 year"
# Data interchange
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"
# Favicon and cursor images
ExpiresByType image/vnd.microsoft.icon "access plus 1 week"
ExpiresByType image/x-icon "access plus 1 week"
# Images
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"
# Video and Audio
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"
# Manifest files
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"
# WebAssembly
ExpiresByType application/wasm "access plus 1 year"
# Web fonts
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"
# Other
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.
Filename Versioning (Recommended)
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:
# Cache CSS for 1 year
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"
The ideal caching strategy is: cache HTML for zero seconds (always revalidate to pick up new asset references), and cache CSS, JS, images, and fonts for one year with versioned filenames. This gives you the best of both worlds: maximum cache efficiency for assets and instant updates when you deploy changes.
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.