View contents
Introduction
HTTP caching allows browsers to store copies of resources locally, eliminating the need to download them again on subsequent visits. Proper caching can reduce page load times by 50-90% for returning visitors and significantly decrease server load and bandwidth costs.
Understanding how caching headers work is essential for delivering fast, efficient web experiences while ensuring users always see up-to-date content when needed.
How Browser Caching Works
When a browser requests a resource, the server’s response headers tell the browser if and how to cache that resource:
HTTP Caching Flow:
┌─────────────────────────────────────────────────────────────┐
│ First Visit: │
│ 1. Browser requests: GET /styles.css │
│ 2. Server responds with resource + cache headers: │
│ Cache-Control: max-age=31536000 │
│ ETag: "abc123" │
│ 3. Browser stores resource in local cache │
│ │
│ Subsequent Visits (within max-age): │
│ 1. Browser checks local cache │
│ 2. Cache is fresh → Uses cached copy (no network request!) │
│ 3. Page loads instantly from cache │
│ │
│ After max-age expires: │
│ 1. Browser sends conditional request with ETag │
│ 2. Server returns 304 Not Modified (if unchanged) │
│ 3. Browser uses cached copy (minimal bandwidth) │
└─────────────────────────────────────────────────────────────┘
Key Caching Headers
Cache-Control
The primary header for controlling caching behavior:
| Directive | Meaning | Example Use |
|---|---|---|
max-age=N |
Cache for N seconds | Static assets |
no-cache |
Must revalidate before using | HTML pages |
no-store |
Never cache | Sensitive data |
public |
Can be cached by CDNs | Static resources |
private |
Only browser can cache | User-specific data |
immutable |
Never changes (skip revalidation) | Versioned assets |
ETag (Entity Tag)
A unique identifier for a specific version of a resource:
Server Response:
ETag: "33a64df5"
Browser Revalidation Request:
If-None-Match: "33a64df5"
Server Response (if unchanged):
304 Not Modified
Last-Modified
Timestamp-based validation (older than ETag but still useful):
Server Response:
Last-Modified: Wed, 15 Dec 2025 10:00:00 GMT
Browser Revalidation Request:
If-Modified-Since: Wed, 15 Dec 2025 10:00:00 GMT
Expires (Legacy)
Sets an absolute expiration date. Use Cache-Control: max-age instead when possible.
Caching Strategies by Resource Type
Different resources need different caching strategies:
| Resource Type | Recommended Cache Policy | Why |
|---|---|---|
| HTML | no-cache or short max-age |
Content changes frequently |
| CSS/JS (versioned) | max-age=31536000, immutable |
Filename changes on update |
| Images | max-age=31536000 |
Rarely change |
| Fonts | max-age=31536000 |
Never change |
| API responses | no-store or short max-age |
Data freshness critical |
Versioned vs Non-Versioned Assets
Versioned (Recommended):
├── styles.abc123.css → max-age=1 year (immutable)
├── app.def456.js → max-age=1 year (immutable)
└── When content changes, filename changes
Non-Versioned (Problematic):
├── styles.css → max-age=? (risky)
├── app.js → max-age=? (risky)
└── Users may get stale content
Impact on Performance
Proper caching dramatically improves performance metrics:
| Metric | First Visit | Cached Visit | Improvement |
|---|---|---|---|
| Page Load Time | 3.2s | 0.8s | 75% faster |
| Transferred Data | 2.1 MB | 45 KB | 98% less |
| Server Requests | 45 | 5 | 89% fewer |
| TTFB | 450ms | 0ms* | 100% eliminated |
*Cached resources have zero TTFB since no network request is made.
Common Caching Problems
Problem 1: No Cache Headers
Issue: Server doesn’t send caching headers; browser uses heuristics.
Solution: Always explicitly set Cache-Control headers.
Problem 2: Too Short max-age
Issue: Static assets cached for only hours or days.
Solution: Use 1 year (31536000 seconds) for versioned static assets.
Problem 3: Caching HTML
Issue: HTML pages cached too aggressively; users see stale content.
Solution: Use no-cache for HTML to always revalidate.
Problem 4: No ETag/Last-Modified
Issue: Browser can’t revalidate; must re-download expired resources.
Solution: Configure server to send ETags for efficient revalidation.
Checking Your Cache Headers
Using Browser DevTools
- Open DevTools → Network tab
- Click on any resource
- Check Response Headers for:
Cache-ControlETagorLast-ModifiedExpires
Using curl
curl -I https://example.com/styles.css
# Look for:
# Cache-Control: max-age=31536000
# ETag: "abc123"
Using Lighthouse
Run a Lighthouse audit and check:
- “Serve static assets with an efficient cache policy”
- Shows resources with short or missing cache TTLs
Implementation
Strategy 1: Cache-Control Directives Deep Dive
Complete Directive Reference
Cache-Control Directives:
┌─────────────────────────────────────────────────────────────┐
│ FRESHNESS DIRECTIVES │
│ ├── max-age=<seconds> How long cache is fresh │
│ ├── s-maxage=<seconds> CDN/proxy cache duration │
│ └── stale-while-revalidate=<seconds> Serve stale while │
│ fetching fresh copy │
│ │
│ REVALIDATION DIRECTIVES │
│ ├── no-cache Always revalidate before using │
│ ├── must-revalidate Revalidate after max-age expires │
│ └── proxy-revalidate Proxies must revalidate │
│ │
│ STORAGE DIRECTIVES │
│ ├── no-store Never cache (sensitive data) │
│ ├── private Only browser can cache │
│ └── public CDNs and proxies can cache │
│ │
│ OPTIMIZATION DIRECTIVES │
│ └── immutable Never revalidate (versioned │
│ assets) │
└─────────────────────────────────────────────────────────────┘
Combining Directives
# Versioned static assets (CSS, JS with hash in filename)
Cache-Control: public, max-age=31536000, immutable
# HTML pages (always check for updates)
Cache-Control: no-cache
# Sensitive user data (never cache)
Cache-Control: no-store, private
# API responses (short cache with background refresh)
Cache-Control: public, max-age=60, stale-while-revalidate=600
# CDN-specific longer cache
Cache-Control: public, max-age=3600, s-maxage=86400
Strategy 2: Server Configuration
Nginx Configuration
# /etc/nginx/conf.d/caching.conf
# Versioned static assets - cache forever
location ~* \.(css|js)$ {
# Only if filename contains hash (e.g., app.abc123.js)
if ($uri ~* ".*\.[a-f0-9]{8,}\.") {
add_header Cache-Control "public, max-age=31536000, immutable";
}
# Non-versioned fallback
if ($uri !~* ".*\.[a-f0-9]{8,}\.") {
add_header Cache-Control "public, max-age=86400";
}
add_header Vary "Accept-Encoding";
}
# Images and fonts - long cache
location ~* \.(jpg|jpeg|png|gif|webp|avif|svg|ico|woff|woff2|ttf|eot)$ {
add_header Cache-Control "public, max-age=31536000";
add_header Vary "Accept-Encoding";
}
# HTML - always revalidate
location ~* \.html$ {
add_header Cache-Control "no-cache";
add_header Vary "Accept-Encoding";
}
# API endpoints - short cache with stale-while-revalidate
location /api/ {
add_header Cache-Control "public, max-age=60, stale-while-revalidate=600";
add_header Vary "Accept-Encoding, Authorization";
}
# Enable ETag
etag on;
Apache Configuration
# .htaccess or httpd.conf
<IfModule mod_expires.c>
ExpiresActive On
# Default: 1 month
ExpiresDefault "access plus 1 month"
# HTML: always revalidate
ExpiresByType text/html "access plus 0 seconds"
# CSS/JS: 1 year (use versioned filenames)
ExpiresByType text/css "access plus 1 year"
ExpiresByType application/javascript "access plus 1 year"
# Images: 1 year
ExpiresByType image/jpeg "access plus 1 year"
ExpiresByType image/png "access plus 1 year"
ExpiresByType image/gif "access plus 1 year"
ExpiresByType image/webp "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"
</IfModule>
<IfModule mod_headers.c>
# Versioned assets (files with hash in name)
<FilesMatch "\.[a-f0-9]{8,}\.(css|js)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
# HTML files
<FilesMatch "\.html$">
Header set Cache-Control "no-cache"
</FilesMatch>
# Enable ETag
FileETag MTime Size
</IfModule>
Node.js/Express Configuration
const express = require('express');
const path = require('path');
const app = express();
// Custom caching middleware
const setCacheHeaders = (req, res, next) => {
const url = req.url;
// Versioned assets (contain hash)
if (/\.[a-f0-9]{8,}\.(css|js)$/.test(url)) {
res.set('Cache-Control', 'public, max-age=31536000, immutable');
}
// Images and fonts
else if (/\.(jpg|jpeg|png|gif|webp|svg|woff2?|ttf|eot)$/.test(url)) {
res.set('Cache-Control', 'public, max-age=31536000');
}
// HTML
else if (/\.html$/.test(url) || url === '/') {
res.set('Cache-Control', 'no-cache');
}
// API responses
else if (url.startsWith('/api/')) {
res.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=600');
}
next();
};
app.use(setCacheHeaders);
// Serve static files with ETags
app.use(express.static('public', {
etag: true,
lastModified: true,
maxAge: 0, // Let middleware handle Cache-Control
}));
Strategy 3: Asset Versioning (Cache Busting)
Build Tool Configuration
Webpack
// webpack.config.js
module.exports = {
output: {
filename: '[name].[contenthash:8].js',
chunkFilename: '[name].[contenthash:8].chunk.js',
assetModuleFilename: 'assets/[name].[contenthash:8][ext]',
},
plugins: [
new MiniCssExtractPlugin({
filename: '[name].[contenthash:8].css',
}),
],
};
Vite
// vite.config.js
export default defineConfig({
build: {
rollupOptions: {
output: {
entryFileNames: 'assets/[name].[hash].js',
chunkFileNames: 'assets/[name].[hash].js',
assetFileNames: 'assets/[name].[hash].[ext]',
},
},
},
});
Why Versioning Matters
Without Versioning:
├── User visits site, downloads styles.css
├── You update styles.css
├── User revisits - browser serves cached (old) styles.css
├── User sees broken layout until cache expires
└── Bad user experience!
With Versioning:
├── User visits site, downloads styles.abc123.css
├── You update CSS, build creates styles.def456.css
├── User revisits - browser requests styles.def456.css (new!)
├── User always sees latest styles
└── Great user experience!
Strategy 4: CDN Caching Configuration
Cloudflare
Page Rules:
URL: example.com/assets/*
Settings:
- Cache Level: Cache Everything
- Edge Cache TTL: 1 month
- Browser Cache TTL: 1 year
URL: example.com/*.html
Settings:
- Cache Level: Standard
- Edge Cache TTL: Respect Existing Headers
AWS CloudFront
{
"CacheBehaviors": [
{
"PathPattern": "/assets/*",
"DefaultTTL": 31536000,
"MaxTTL": 31536000,
"MinTTL": 31536000,
"Compress": true
},
{
"PathPattern": "*.html",
"DefaultTTL": 0,
"MaxTTL": 0,
"MinTTL": 0
}
]
}
Vercel
// vercel.json
{
"headers": [
{
"source": "/assets/(.*)",
"headers": [
{
"key": "Cache-Control",
"value": "public, max-age=31536000, immutable"
}
]
},
{
"source": "/(.*).html",
"headers": [
{
"key": "Cache-Control",
"value": "no-cache"
}
]
}
]
}
Strategy 5: Cache Invalidation
Strategies Comparison
| Strategy | How It Works | Pros | Cons |
|---|---|---|---|
| Versioned URLs | Change filename | Instant, reliable | Requires build step |
| Query strings | ?v=123 | Easy to implement | Some CDNs ignore |
| CDN purge | API call to CDN | Flexible | Slow, may miss edges |
| Short TTL | Low max-age | Simple | More requests |
Recommended Approach
Best Practice Cache Invalidation:
┌─────────────────────────────────────────────────────────────┐
│ Static Assets (CSS, JS, Images): │
│ └── Use content-hash in filename (automatic invalidation) │
│ │
│ HTML Pages: │
│ └── Use no-cache (always revalidate) │
│ │
│ API Responses: │
│ └── Use stale-while-revalidate for background refresh │
│ │
│ Emergency Updates: │
│ └── CDN purge API + deploy new versioned assets │
└─────────────────────────────────────────────────────────────┘
Strategy 6: Vary Header for Conditional Caching
When to Use Vary
# Cache different versions based on encoding
Vary: Accept-Encoding
# Cache different versions for different languages
Vary: Accept-Language
# Cache different for authenticated vs anonymous users
Vary: Authorization
# Multiple conditions
Vary: Accept-Encoding, Accept-Language
Nginx Vary Configuration
# Add Vary header for compressed content
location ~* \.(css|js|html|json|xml)$ {
add_header Vary "Accept-Encoding";
gzip on;
}
# Add Vary for language-specific content
location /content/ {
add_header Vary "Accept-Language";
}
Measuring Cache Effectiveness
Before/After Comparison
BEFORE Cache Optimization:
├── Repeat Visit Page Load: 2.8s
├── Transferred Data: 1.9 MB
├── Server Requests: 42
├── Cache Hit Rate: 15%
└── Lighthouse Performance: 68
AFTER Cache Optimization:
├── Repeat Visit Page Load: 0.6s (↓79%)
├── Transferred Data: 85 KB (↓96%)
├── Server Requests: 8 (↓81%)
├── Cache Hit Rate: 94%
└── Lighthouse Performance: 96 (↑28 points)
Lighthouse Audits
Check for these results:
- “Serve static assets with an efficient cache policy” - Should pass
- Shows any resources with cache TTL < 1 week
Testing with DevTools
Network Tab Analysis:
1. Enable "Disable cache" checkbox
2. Load page (simulates first visit)
3. Uncheck "Disable cache"
4. Reload page (simulates repeat visit)
5. Compare "Size" column:
- "(disk cache)" = cached locally
- "(memory cache)" = cached in memory
- Actual size = downloaded from network
Optimization Checklist
Before deploying, verify:
- [ ] Versioned static assets use
max-age=31536000, immutable - [ ] HTML uses
no-cachefor always-fresh content - [ ] Images/fonts use
max-age=31536000 - [ ] ETags enabled for efficient revalidation
- [ ]
Vary: Accept-Encodingset for compressed content - [ ] CDN caching properly configured
- [ ] Build tool generates content hashes in filenames
- [ ] API responses use appropriate caching strategy
- [ ] Sensitive data uses
no-store - [ ] Lighthouse caching audit passes
Related Articles
Learn more about optimizing resource delivery:
- TTFB Optimization Guide - Caching eliminates TTFB for cached resources
- Text Compression Explained - Cache compressed resources
- Render-Blocking Resources Explained - Cache critical resources
📚 Back to Performance SEO Hub - Explore all performance topics
References
- MDN Web Docs - HTTP Caching
- web.dev - HTTP Cache
- Chrome Developers - Serve Static Assets with Efficient Cache Policy
- Google Developers - HTTP Caching
Try It Yourself
Want to check your site’s caching headers?
🔧 Download UXR SEO Analyzer (Free, 100% local analysis)
Disclaimer: The analyzers in this extension are reference guides based on official documentation from MDN, web.dev, and Chrome Developers. They do not represent absolute truths about how search engines evaluate your content—only search engines know their internal algorithms. Use these recommendations as a starting point to improve your site.
Last updated: December 15, 2025