TL;DR / Cache-Control prevents requests entirely during a freshness window, while ETags validate whether cached content has changed -- they solve different problems and work best together.
How It Works
┌────────────┐ ┌─────────────────┐
│ Browser │─────────→│ Cache-Control │
└────────────┘ └─────────────────┘
│ max-age=3600
│ not expired? -> use cache
│
┌────┘ expired?
│
│
↓
┌──────────────────────────┐
│ Send If-None-Match: │
│ "etag" │
└──────────────────────────┘
│
│
┌────────────────└────────────┐
│ │
↓ ↓
┌────────────────────┐ ┌────────────────────┐
│ 304 Not Modified │ │ 200 + new body │
│ (no body sent) │ │ (content changed) │
└────────────────────┘ └────────────────────┘
HTTP caching has two fundamentally different mechanisms. Expiration-based caching (Cache-Control with max-age or Expires) tells the client how long a response is valid. No request is made during that window. Validation-based caching (ETag with If-None-Match, or Last-Modified with If-Modified-Since) asks the server whether the cached copy is still current. A request is always made, but the response may be a lightweight 304 instead of the full body.
Cache-Control: Expiration-Based
Cache-Control: max-age=3600 means the response is fresh for 3600 seconds from the time it was received. During this window, the browser serves the cached response without contacting the server at all. Zero network latency, zero server load. The Date header on the response establishes the clock reference.
Additional directives refine the behavior. public allows shared caches (CDNs, proxies) to store the response. private restricts it to the user's browser cache only -- critical for responses containing user-specific data. no-cache does not mean "do not cache" -- it means "cache it, but always revalidate before using." no-store is the true "do not cache" directive: the response must not be stored anywhere.
immutable tells the browser that the content at this URL will never change. Combined with a long max-age, it eliminates revalidation requests entirely. This is designed for content-hashed URLs (app.a1b2c3.js) where the URL itself changes when the content changes.
s-maxage sets a different TTL for shared caches (CDNs) than max-age sets for the browser. A common pattern is max-age=0, s-maxage=3600 -- the browser always revalidates with the CDN, but the CDN caches for an hour and absorbs the origin load.
ETag: Validation-Based
An ETag is an opaque identifier for a specific version of a resource. The server generates it (typically a content hash or version number) and sends it in the ETag header. The browser stores it alongside the cached response. When the cache entry expires (or when no-cache forces revalidation), the browser sends a conditional request with If-None-Match: "the-etag-value".
If the server determines the content has not changed (the current ETag matches), it responds with 304 Not Modified and no body. The browser reuses the cached response. If the content changed, the server responds with 200, the full new body, and a new ETag. The round trip still happens either way, but the 304 response is tiny compared to retransmitting the full response body.
ETags come in two forms: strong and weak. A strong ETag (e.g., "abc123") means the representations are byte-for-byte identical. A weak ETag (e.g., W/"abc123") means the representations are semantically equivalent but may differ in insignificant ways (whitespace, encoding). Weak ETags cannot be used for range requests. Most servers generate strong ETags by default.
Last-Modified: The Simpler Validator
Last-Modified predates ETags and uses timestamps instead of hashes. The browser sends If-Modified-Since with the stored timestamp. Precision is limited to one second, and clock skew between servers can cause incorrect results. ETags are strictly more capable, but Last-Modified is simpler and adequate for many use cases.
When both headers are present, the server must check If-None-Match first (per the HTTP spec); If-Modified-Since is only evaluated if If-None-Match is absent.
How They Work Together
The optimal pattern uses both mechanisms. Cache-Control provides the fast path: during the freshness window, no request is made at all. ETag provides the fallback: after the freshness window expires, the conditional request avoids re-downloading unchanged content.
For static assets with content-hashed filenames: Cache-Control: public, max-age=31536000, immutable. No ETag needed -- the URL changes when the content changes.
For HTML documents: Cache-Control: no-cache with an ETag. The browser always revalidates (ensuring users see current content), but the 304 response avoids re-downloading the full HTML when it has not changed.
For API responses: Cache-Control: private, max-age=60 with an ETag. The browser uses cached data for 60 seconds, then revalidates. The private directive prevents CDNs from caching user-specific data.
CDN Interactions
CDNs add a caching layer between browser and origin, each with its own Cache-Control settings (s-maxage for the CDN, max-age for the browser). CDN cache keys typically include the URL and Vary headers. The Vary header tells caches that the response differs based on specific request headers (e.g., Vary: Accept-Encoding means gzipped and uncompressed versions are cached separately).
Gotchas
no-cachedoes not prevent caching -- it forces revalidation on every use. Useno-storeto actually prevent the response from being cached. This naming confusion has persisted since HTTP/1.0.- ETag validation still requires a network round trip -- a
304saves bandwidth by omitting the body, but the latency of the request/response cycle remains. For latency-sensitive applications,max-agewith a non-zero value is essential. - CDNs may strip or override
Cache-Controlheaders -- some CDN configurations add their own caching directives. Verify actual response headers in the browser, not just what your origin sends. Vary: *disables caching entirely -- it tells caches that the response varies on every possible dimension, making it uncacheable. Some frameworks accidentally set this.- ETags generated from
inode-mtime-size(Apache's default) change across server instances -- in a load-balanced setup, different servers generate different ETags for identical content, causing unnecessary cache misses. Use content-hash-based ETags in distributed environments.