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.
On this page
- Introduction
- Cache Control Fundamentals
- Cache-Control Directives
- Cache Storage Locations
- ETag Generation and Validation
- ETag Types
- ETag Generation Strategies
- Conditional Request Validation
- If-None-Match Validation
- If-Modified-Since Validation
- Cache Lifecycle Examples
- Fresh Cache Scenario
- Stale Cache with Successful Validation
- Stale Cache with Failed Validation
- Cache Invalidation Strategies
- Time-Based Invalidation
- Event-Based Invalidation
- Versioned URLs
- Advanced Caching Patterns
- Stale-While-Revalidate
- Vary Header for Content Negotiation
- Implementation Examples
- Express.js Caching Middleware
- CDN Cache Configuration
- Getting Headers and Caching right
- Cache Strategy Selection
- Performance Monitoring
- Common Pitfalls
- Related Concepts
TL;DR: HTTP caching is controlled mostly by
Cache-Control. Setmax-ageto say how long a response stays fresh,privateorno-storefor user-specific data, and send anETagso caches can revalidate with a cheap304 Not Modifiedinstead of downloading everything again. Versioned static assets getmax-age=31536000, immutable.
HTTP caching has the biggest effect on web performance of anything in this series, because it decides whether the next request reaches your origin at all. With correct headers, pages load faster, the origin serves fewer requests, and cache behaviour becomes predictable when you debug it.
Introduction
Caches keep reusable copies of responses at several layers: the browser, a CDN, a reverse proxy. Storing the copy is easy. The hard part is telling each layer when reuse is safe and when it has to check back with the origin first.
HTTP handles that with two sets of headers. Cache-control headers say how long a response stays fresh and who is allowed to store it. Validators such as ETag and Last-Modified let a cache ask “is my copy still current?” without downloading the whole thing again.
Get it right and pages load faster with less origin traffic. Get it wrong and users see stale data, or someone else’s.
Cache Control Fundamentals
Cache-Control Directives
Cache-Control is the main header you’ll use. Its directives say who may store a response, for how long, and when to check back with the server.
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
Directive Meanings:
max-age=N: the response stays fresh until its age reaches N secondspublic: any cache may store it, including CDNs and proxiesprivate: only the user’s browser may store it; shared caches must notno-cache: caches may store it, but have to revalidate with the server before every reuseno-store: no cache may store it at allmust-revalidate: once stale, the cache has to revalidate before reusing it
Cache Storage Locations
Browser Cache:
Cache-Control: private, max-age=300
The browser keeps the copy on the user’s device. It’s the fastest cache, but each copy serves only that one user.
CDN/Proxy Cache:
Cache-Control: public, max-age=86400
CDNs and proxies are shared caches: one stored copy serves many users. They’re a great fit for static assets and anything else that’s the same for everyone.
Application Cache:
Cache-Control: no-cache, private
Redis or Memcached sit behind your app, outside HTTP, so Cache-Control doesn’t control them. This header suits dynamic pages your app builds: the browser may keep a copy but revalidates before using it.
ETag Generation and Validation
An ETag (entity tag) is a fingerprint for one version of a resource. When the content changes, the ETag changes. Caches send it back to ask whether their copy is still current, without downloading the resource again.
ETag Types
Strong ETags:
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
A strong ETag means byte-for-byte identical content. Any change, however small, gets a new one.
Weak ETags:
ETag: W/"33a64df551425fcc55e4d42a148795d9f25f89d4"
A weak ETag (the W/ prefix) means the content is equivalent, not necessarily identical. It may stay the same across minor changes that don’t matter to the client.
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}"`)
Timestamp-Based ETags:
// 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)
Conditional Request Validation
A conditional request lets a client check whether its cached copy is still good. If it is, the server skips the body and the client reuses what it has.
If-None-Match Validation
The most common form sends the stored ETag back in If-None-Match. The requests and responses in this guide are examples.
Client Request:
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
Server Response (Content Changed):
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
The older alternative uses timestamps: the client sends the Last-Modified value it stored.
Client Request:
GET /api/users/123 HTTP/1.1
Host: api.example.com
If-Modified-Since: Sun, 18 Jan 2026 09:30:00 GMT
Server Response (Not Modified):
HTTP/1.1 304 Not Modified
Last-Modified: Sun, 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
Long TTL for Static Assets:
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'])
Cache Tags:
Some CDNs purge by tag. The header is vendor-specific; Cloudflare reads Cache-Tag:
Cache-Control: max-age=3600
Cache-Tag: 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" />
Advanced Caching Patterns
Stale-While-Revalidate
The cache serves the stale copy right away and refreshes it in the background, so users don’t wait on the origin.
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
Vary tells caches to store a separate copy for each value of the listed request headers.
Vary: Accept-Encoding, Accept-Language
Cache-Control: public, max-age=3600
Cache Keys:
/api/users+Accept-Encoding: gzip+Accept-Language: en/api/users+Accept-Encoding: br+Accept-Language: es
Implementation Examples
Express.js Caching Middleware
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}`
})
// Check if client has current version
if (req.headers['if-none-match'] === etag) {
return res.status(304).end()
}
originalSend.call(this, data)
}
next()
}
}
generateETag is a placeholder for your hashing function. Express already sends weak ETags and answers a matching If-None-Match with 304 by default. This version leaves out Last-Modified: stamping the current time on every response would claim the resource changed on every request.
CDN Cache Configuration
// Cloudflare Workers example
addEventListener('fetch', (event) => {
event.respondWith(handleRequest(event))
})
async function handleRequest(event) {
const request = event.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; copy the response so its headers can be changed
const originResponse = await fetch(request)
response = new Response(originResponse.body, originResponse)
// 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
}
Getting Headers and Caching right
Cache Strategy Selection
Static Assets:
Cache-Control: public, max-age=31536000, immutable
Cache for a year and change the URL when the file changes.
API Responses:
Cache-Control: private, max-age=300, must-revalidate
A few minutes in the browser only, then revalidate.
User-Specific Content:
Cache-Control: private, no-cache
The browser may store it but checks with the server every time.
Performance Monitoring
Cache Hit Ratio:
const cacheHitRatio = cacheHits / (cacheHits + cacheMisses)
// Track this over time; a good ratio depends on your traffic mix
Cache Validation Efficiency:
const validationSuccessRate = notModifiedResponses / validationRequests
// Expect this to be high for content that rarely changes
Common Pitfalls
Over-Caching Dynamic Content:
// Bad: User data cached too long, and by shared caches
Cache-Control: max-age=3600
// Good: Browser-only, short, with validation
Cache-Control: private, max-age=60, must-revalidate
Under-Caching Static Assets:
// 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
Where to go next:
- 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
Start with short cache times and lengthen them as you learn how often your content really changes.
Check your understanding
Design a cache policy instead of memorizing directives
Build two different Cache-Control headers: one for versioned static assets and one for personalized account data.
- Create a long-lived policy for static assets that can be safely cached for a long time.
- Create a second policy for user-specific HTML or JSON that should not leak through shared caches.
- Compare the resulting directives and note why one can be public while the other should stay private or avoid storage.
Look for: The static asset policy should favor long freshness, while the personalized policy should prevent unsafe reuse.
Why it matters: Caching decisions are really data-sharing decisions with performance consequences.
Choose the right caching behavior
A browser already has a cached response with an ETag. It sends `If-None-Match`, and the content has not changed. Which response is most appropriate?
Frequently asked questions
How does HTTP caching work?
Servers send Cache-Control headers telling browsers and CDNs how long a response stays fresh. While fresh, caches reuse it without contacting the server; once stale, they revalidate.
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?
The cache sends its stored ETag or Last-Modified value back to ask whether its copy is still current. If nothing changed, the server answers 304 Not Modified with no body, saving bandwidth.
How do I cache static assets effectively?
Use a long max-age (a year) with versioned filenames such as app.v2.js. When content changes, change the filename, so browsers fetch the new file immediately.
Sources
Related
HTTP Compression: gzip, Brotli and Zstandard Setup
How HTTP compression works: Accept-Encoding negotiation, gzip vs Brotli vs zstd, nginx, Caddy, Express and Cloudflare config, BREACH, and measuring with curl.
CDN Not Caching: cf-cache-status DYNAMIC, BYPASS, MISS
CDN not caching? Read cf-cache-status DYNAMIC, BYPASS, MISS or X-Cache: Miss from cloudfront, then fix Set-Cookie, Cache-Control, Vary and cache keys.
HTTP/3 and QUIC Explained: Setup, Fallback, 0-RTT
How HTTP/3 and QUIC work: UDP/443, connection IDs, 0-RTT replay risk, Alt-Svc and HTTPS DNS record discovery, nginx/Caddy/Cloudflare setup, and curl checks.
HTTP Range Requests: 206, Resume and Video Seeking
How HTTP range requests work: Range, Accept-Ranges, 206 and Content-Range, multipart/byteranges, 416, If-Range for safe resumes, and CDN and compression traps.