Guide
HTTP Headers and Caching: A Practical Guide
Master HTTP caching with Cache-Control, ETag, Last-Modified, and conditional request headers. Learn how to optimize performance with proper cache strategies.
TL;DR: HTTP caching uses Cache-Control headers to specify how long resources should be cached and ETag/If-None-Match for validation. Proper caching reduces load times by 50-90% and decreases server load.
HTTP caching is one of the most powerful performance optimization techniques available to web developers. By storing frequently requested resources closer to users and avoiding unnecessary data transfers, caching can reduce load times by 50-90% and significantly decrease server load. This guide explores how HTTP headers control caching behavior, from initial storage decisions to cache validation and invalidation.
Introduction
Caching works by storing copies of resources at various points between the client and server. When a cached resource is requested again, it can be served immediately without contacting the origin server. However, this introduces a fundamental challenge: how do we ensure cached content remains fresh and accurate?
HTTP solves this through a sophisticated system of cache control headers and validation mechanisms. These headers allow servers to specify exactly how long resources should be cached, under what conditions they can be reused, and how to check if cached content is still valid.
Understanding caching is crucial because it affects every aspect of web performance: page load times, bandwidth usage, server costs, and user experience. A well-designed caching strategy can make the difference between a fast, responsive application and one that feels sluggish and expensive to operate.
Cache Control Fundamentals
Cache-Control Directives
The Cache-Control header is the primary mechanism for controlling caching behavior. It uses directives that specify how, where, and for how long resources should be cached.
Common Cache-Control Directives:
Cache-Control: max-age=3600
Cache-Control: no-cache
Cache-Control: no-store
Cache-Control: public, max-age=86400
Cache-Control: private, max-age=300, must-revalidate
```text
**Directive Meanings:**
- `max-age=N`: Cache for N seconds from response time
- `public`: Can be cached by any cache (browsers, CDNs, proxies)
- `private`: Only cacheable by browser, not shared caches
- `no-cache`: Must revalidate with server before using cached copy
- `no-store`: Never cache this response
- `must-revalidate`: Must revalidate when cache expires
### Cache Storage Locations
**Browser Cache:**
```http
Cache-Control: private, max-age=300
Stores resources locally on user’s device. Fast access but limited to single user.
CDN/Proxy Cache:
Cache-Control: public, max-age=86400
```text
Shared caches that serve multiple users. Excellent for static assets.
**Application Cache:**
```http
Cache-Control: no-cache, private
Server-side caching (Redis, Memcached) for dynamic content generation.
ETag Generation and Validation
ETags (Entity Tags) provide a mechanism for cache validation without transferring the entire resource. They’re essentially fingerprints that change when content changes.
ETag Types
Strong ETags:
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
```text
Indicates byte-for-byte identical content. Changes with any modification.
**Weak ETags:**
```http
ETag: W/"33a64df551425fcc55e4d42a148795d9f25f89d4"
Indicates semantically equivalent content. May not change for minor modifications.
ETag Generation Strategies
Content-Based ETags:
// Generate ETag from content hash
const crypto = require('crypto')
const content = JSON.stringify(userData)
const etag = crypto.createHash('md5').update(content).digest('hex')
response.setHeader('ETag', `"${etag}"`)
```javascript
**Timestamp-Based ETags:**
```javascript
// Generate ETag from last modified time
const lastModified = new Date(user.updatedAt)
const etag = lastModified.getTime().toString(16)
response.setHeader('ETag', `"${etag}"`)
Version-Based ETags:
// Generate ETag from resource version
const etag = `"v${user.version}"`
response.setHeader('ETag', etag)
```text
## Conditional Request Validation
Conditional requests allow clients to validate cached content without downloading it again. This saves bandwidth and improves performance.
### If-None-Match Validation
The most common validation mechanism uses ETags with the `If-None-Match` header.
**Client Request:**
```http
GET /api/users/123 HTTP/1.1
Host: api.example.com
If-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Server Response (Content Unchanged):
HTTP/1.1 304 Not Modified
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Cache-Control: max-age=300
Content-Length: 0
```text
**Server Response (Content Changed):**
```http
HTTP/1.1 200 OK
ETag: "7d865e959b2466918c9863afca942d0fb89d7c9ac0c99bafc3749504ded97730"
Cache-Control: max-age=300
Content-Type: application/json
{
"id": 123,
"name": "Alice Johnson",
"email": "alice.johnson@example.com",
"updatedAt": "2026-01-18T10:30:00Z"
}
If-Modified-Since Validation
An alternative validation mechanism using timestamps.
Client Request:
GET /api/users/123 HTTP/1.1
Host: api.example.com
If-Modified-Since: Fri, 18 Jan 2026 09:30:00 GMT
```text
**Server Response (Not Modified):**
```http
HTTP/1.1 304 Not Modified
Last-Modified: Fri, 18 Jan 2026 09:30:00 GMT
Cache-Control: max-age=300
Cache Lifecycle Examples
Fresh Cache Scenario
1. Initial Request:
GET /api/products
→ 200 OK, Cache-Control: max-age=600, ETag: "abc123"
2. Subsequent Request (within 10 minutes):
GET /api/products
→ Served from cache (no network request)
3. Cache Status: FRESH
Time remaining: 400 seconds
Stale Cache with Successful Validation
1. Cache Expired:
Cached response age: 650 seconds (> max-age=600)
2. Validation Request:
GET /api/products
If-None-Match: "abc123"
3. Server Response:
304 Not Modified, ETag: "abc123"
4. Cache Status: FRESH (revalidated)
New expiration: current_time + 600 seconds
Stale Cache with Failed Validation
1. Cache Expired:
Cached response age: 650 seconds
2. Validation Request:
GET /api/products
If-None-Match: "abc123"
3. Server Response:
200 OK, ETag: "def456", [new content]
4. Cache Status: FRESH (updated)
Old cache entry replaced with new response
Cache Invalidation Strategies
Time-Based Invalidation
Short TTL for Dynamic Content:
Cache-Control: max-age=60, must-revalidate
```text
**Long TTL for Static Assets:**
```http
Cache-Control: public, max-age=31536000, immutable
Event-Based Invalidation
Cache Purging:
// Purge specific URLs from CDN
await cdn.purge(['/api/users/123', '/api/users/123/profile'])
```http
**Cache Tags:**
```http
Cache-Control: max-age=3600
Cache-Tags: user:123, profile, api
Versioned URLs
Asset Versioning:
<!-- Old version -->
<link rel="stylesheet" href="/styles.css?v=1.2.3" />
<!-- New version (cache miss) -->
<link rel="stylesheet" href="/styles.css?v=1.2.4" />
```text
## Advanced Caching Patterns
### Stale-While-Revalidate
Serve stale content immediately while updating cache in background.
```http
Cache-Control: max-age=300, stale-while-revalidate=86400
Behavior:
- 0-300 seconds: Serve from cache
- 300-86700 seconds: Serve stale content, trigger background update
- 86700+ seconds: Must revalidate before serving
Vary Header for Content Negotiation
Cache different versions based on request headers.
Vary: Accept-Encoding, Accept-Language
Cache-Control: public, max-age=3600
```javascript
**Cache Keys:**
- `/api/users` + `Accept-Encoding: gzip` + `Accept-Language: en`
- `/api/users` + `Accept-Encoding: br` + `Accept-Language: es`
## Implementation Examples
### Express.js Caching Middleware
```javascript
function cacheMiddleware(maxAge = 300) {
return (req, res, next) => {
// Generate ETag from response content
const originalSend = res.send
res.send = function (data) {
const etag = generateETag(data)
res.set({
ETag: etag,
'Cache-Control': `max-age=${maxAge}`,
'Last-Modified': new Date().toUTCString()
})
// Check if client has current version
if (req.headers['if-none-match'] === etag) {
return res.status(304).end()
}
originalSend.call(this, data)
}
next()
}
}
CDN Cache Configuration
// Cloudflare Workers example
addEventListener('fetch', (event) => {
event.respondWith(handleRequest(event.request))
})
async function handleRequest(request) {
const cache = caches.default
const cacheKey = new Request(request.url, request)
// Check cache first
let response = await cache.match(cacheKey)
if (!response) {
// Fetch from origin
response = await fetch(request)
// Cache based on content type
if (response.headers.get('content-type')?.includes('application/json')) {
response.headers.set('Cache-Control', 'max-age=300')
}
// Store in cache
event.waitUntil(cache.put(cacheKey, response.clone()))
}
return response
}
```text
## Best Practices
### Cache Strategy Selection
**Static Assets:**
```http
Cache-Control: public, max-age=31536000, immutable
Long-term caching with versioned URLs.
API Responses:
Cache-Control: private, max-age=300, must-revalidate
```text
Short-term caching with validation.
**User-Specific Content:**
```http
Cache-Control: private, no-cache
Always validate before serving.
Performance Monitoring
Cache Hit Ratio:
const cacheHitRatio = cacheHits / (cacheHits + cacheMisses)
// Target: > 80% for static content, > 60% for dynamic content
```javascript
**Cache Validation Efficiency:**
```javascript
const validationSuccessRate = notModifiedResponses / validationRequests
// Target: > 70% for stable content
Common Pitfalls
Over-Caching Dynamic Content:
// Bad: User data cached too long
Cache-Control: max-age=3600
// Good: Short cache with validation
Cache-Control: max-age=60, must-revalidate
```text
**Under-Caching Static Assets:**
```http
// Bad: CSS/JS files expire quickly
Cache-Control: max-age=300
// Good: Long-term caching with versioning
Cache-Control: public, max-age=31536000, immutable
Related Concepts
HTTP caching integrates with many other web technologies:
- Request Lifecycle: How caching fits into the overall request flow
- Status Codes: 304 Not Modified and other cache-related codes
- ETag Header: Detailed ETag implementation
- Cache-Control Header: Complete directive reference
- If-None-Match Header: Conditional request validation
Effective caching requires understanding the interplay between these headers and the specific needs of your application. Start with conservative cache times and gradually optimize based on your content update patterns and performance requirements.
Frequently Asked Questions
How does HTTP caching work?
Servers send Cache-Control headers telling browsers and CDNs how long to cache responses. Cached responses are reused without contacting the server.
What is the difference between no-cache and no-store?
no-cache allows caching but requires revalidation before use. no-store prevents any caching. Use no-store for sensitive data, no-cache for frequently updated content.
What is cache revalidation?
Revalidation checks if cached content is still valid using ETag or Last-Modified. If unchanged, server returns 304 Not Modified, saving bandwidth.
How do I cache static assets effectively?
Use long max-age (1 year) with versioned filenames (app.v2.js). When content changes, change the filename. This enables aggressive caching with instant updates.