# 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.

Source: https://howhttpworks.com/guides/headers-and-caching
Last reviewed: 2026-10-05

> **TL;DR:** HTTP caching is controlled mostly by `Cache-Control`. Set `max-age` to say how long a response stays fresh, `private` or `no-store` for user-specific data, and send an `ETag` so caches can revalidate with a cheap `304 Not Modified` instead of downloading everything again. Versioned static assets get `max-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:**

```http
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 seconds
- `public`: any cache may store it, including CDNs and proxies
- `private`: only the user's browser may store it; shared caches must not
- `no-cache`: caches may store it, but have to revalidate with the server before every reuse
- `no-store`: no cache may store it at all
- `must-revalidate`: once stale, the cache has to revalidate before reusing it

### Cache Storage Locations

**Browser Cache:**

```http
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:**

```http
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:**

```http
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:**

```http
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
```

A strong ETag means byte-for-byte identical content. Any change, however small, gets a new one.

**Weak ETags:**

```http
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:**

```javascript
// 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:**

```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:**

```javascript
// 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:**

```http
GET /api/users/123 HTTP/1.1
Host: api.example.com
If-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4"
```

**Server Response (Content Unchanged):**

```http
HTTP/1.1 304 Not Modified
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Cache-Control: max-age=300
```

**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

The older alternative uses timestamps: the client sends the `Last-Modified` value it stored.

**Client Request:**

```http
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
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

```text
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

```text
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

```text
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:**

```http
Cache-Control: max-age=60, must-revalidate
```

**Long TTL for Static Assets:**

```http
Cache-Control: public, max-age=31536000, immutable
```

### Event-Based Invalidation

**Cache Purging:**

```javascript
// 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`:

```http
Cache-Control: max-age=3600
Cache-Tag: user:123, profile, api
```

### Versioned URLs

**Asset Versioning:**

```html
<!-- 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.

```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

`Vary` tells caches to store a separate copy for each value of the listed request headers.

```http
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

```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}`
      })

      // 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

```javascript
// 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:**

```http
Cache-Control: public, max-age=31536000, immutable
```

Cache for a year and change the URL when the file changes.

**API Responses:**

```http
Cache-Control: private, max-age=300, must-revalidate
```

A few minutes in the browser only, then revalidate.

**User-Specific Content:**

```http
Cache-Control: private, no-cache
```

The browser may store it but checks with the server every time.

### Performance Monitoring

**Cache Hit Ratio:**

```javascript
const cacheHitRatio = cacheHits / (cacheHits + cacheMisses)
// Track this over time; a good ratio depends on your traffic mix
```

**Cache Validation Efficiency:**

```javascript
const validationSuccessRate = notModifiedResponses / validationRequests
// Expect this to be high for content that rarely changes
```

### Common Pitfalls

**Over-Caching Dynamic Content:**

```http
// 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:**

```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

Where to go next:

- **[Request Lifecycle](https://howhttpworks.com/guides/request-lifecycle)**: How caching fits into the overall request flow
- **[Status Codes](https://howhttpworks.com/guides/status-codes-overview)**: 304 Not Modified and other cache-related codes
- **[ETag Header](https://howhttpworks.com/headers/etag)**: Detailed ETag implementation
- **[Cache-Control Header](https://howhttpworks.com/headers/cache-control)**: Complete directive reference
- **[If-None-Match Header](https://howhttpworks.com/headers/if-none-match)**: Conditional request validation

Start with short cache times and lengthen them as you learn how often your content really changes.
