How HTTP Works

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.

Reviewed 7 min readintermediate2 sourcesTry itMarkdown
On this page

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:

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:

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

Where to go next:

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

Check your understanding

Guided Lab

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.

Open Cache Builder
  1. Create a long-lived policy for static assets that can be safely cached for a long time.
  2. Create a second policy for user-specific HTML or JSON that should not leak through shared caches.
  3. 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.

Interactive Check

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?

Choose the right caching behavior

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

  1. MDN Web Docs: HTTP Cachingdeveloper.mozilla.org
  2. RFC 9111: HTTP Cachingrfc-editor.org

Keep going

Browse /search