ETag and Conditional Requests
A conditional request is the HTTP mechanism that lets a cache ask "do you still have the same version I cached?" without re-downloading the response body. The server answers yes with a 304 Not Modified (no body, a few hundred bytes of headers) or no with a 200 OK and the full new response. This single exchange is the difference between a megabyte of bandwidth and 200 bytes per revalidation — and it is the mechanism by which every browser cache, every CDN, and every reverse proxy avoids unnecessary transfer when content has not changed.
The two validators: ETag and Last-Modified
HTTP defines two ways for a server to identify a specific version of a resource:
| Validator | Response header | Conditional request header | Resolution |
|---|---|---|---|
| Last-Modified | Last-Modified: Wed, 21 Oct 2026 07:28:00 GMT | If-Modified-Since | 1 second |
| ETag | ETag: "33a64df551" | If-None-Match | Whatever the server chooses (typically content hash) |
A response can carry both. When a cache revalidates, it can send either or both conditional headers; the server should consult them and return 304 if any of them matches the current version.
The conditional GET flow
An end-to-end revalidation looks like this:
- The cache has a stored response for
/logo.pngwithETag: "abc123"andLast-Modified: Wed, 21 Oct 2026 07:28:00 GMT. Its max-age has expired. - The cache sends
GET /logo.pngto origin withIf-None-Match: "abc123"andIf-Modified-Since: Wed, 21 Oct 2026 07:28:00 GMT. - Origin compares the current resource version. If the ETag still matches (or the resource hasn't changed since the date), it returns:
The body is empty. The cache updates the stored entry's freshness window and continues serving the existing body.HTTP/1.1 304 Not Modified ETag: "abc123" Cache-Control: public, max-age=300 - If the resource has changed, origin returns a normal 200 with the new body, ETag, and Last-Modified. The cache replaces the stored entry.
Strong vs weak validators
ETags come in two flavors:
- Strong ETag:
ETag: "abc123"— guarantees the two representations are byte-identical. Safe for range requests, partial updates, and resumed downloads. - Weak ETag:
ETag: W/"abc123"— guarantees the representations are semantically equivalent but may differ in trivial bytes. Two responses with the same weak ETag could differ in whitespace, encoding, or compression metadata. Range requests are not safe with weak ETags.
For most content, weak ETags are correct: you do not actually care if two compressed copies of the same logical resource differ by a byte of gzip metadata. Strong ETags are useful when you genuinely need byte-precise comparisons (resumable downloads, byte-range video segments).
Where ETags come from
Common strategies for generating ETags:
| Strategy | Pros | Cons |
|---|---|---|
| Content hash (SHA-256, MD5) | Deterministic, content-addressed, ideal | Cost of hashing on every response |
| File inode + mtime + size (nginx default) | Cheap, no rehashing | Changes across deploys/servers even with identical content |
| Database row version | Cheap for dynamic content | Only valid for single-row representations |
| Application-level cache-buster | Easy to reason about | Manual; error-prone |
For CDN-friendly content, the ideal is a hash of the response body. That way, two servers, two deploys, or two requests that produce identical bytes also produce identical ETags — and the 304 path works across infrastructure.
Conditional requests and freshness
Conditional requests interact with freshness directives but do not replace them. The flow:
- While the response is fresh (within max-age), the cache serves it directly without revalidation. ETag is not consulted.
- Once stale, the cache revalidates. ETag is sent in If-None-Match. The 304 path is taken if the validator still matches.
So ETag does not save you from sending traffic during the freshness window — it makes the post-freshness revalidation cheap. Combined with stale-while-revalidate (see SWR explained), the background revalidation becomes both fast (user gets the stale copy immediately) and cheap (the background fetch is a 304 most of the time).
Why a cache might never see a 304
If you expect 304 traffic and don't see it, the usual culprits:
- ETag changes across responses for unchanged content. Default nginx ETags include the file mtime, which changes when you deploy even if content is identical. Disable default ETag generation and emit content-hash ETags from the application.
- Compression rewrites ETag. Some proxies recompute ETag after gzip/brotli. The recompressed ETag differs from the origin's, so revalidation fails.
- The application doesn't process If-None-Match. Many frameworks always return 200 because the routing layer never reaches the conditional-response code path.
- A middleware strips ETag. Some CORS, security, or logging middlewares modify response headers and inadvertently drop ETag.
- Vary mismatch. If the response varies on a header the cache cannot replicate, the cache may not even attempt revalidation against the stored entry.
Other conditional headers
Two less common but useful conditionals:
- If-Match — the opposite of If-None-Match. Used for optimistic concurrency control on PUT/DELETE. "Only apply this change if the resource still has ETag X." Origin returns 412 Precondition Failed if not.
- If-Unmodified-Since — date-based version of If-Match. Less reliable due to 1-second resolution.
These two are not used for caching but they share the conditional-request mechanism. They are how REST APIs implement safe concurrent updates without external locking.
304 size vs 200 size: the bandwidth math
A typical 304 response is around 200–400 bytes of headers — Status, Date, Server, ETag, Cache-Control, Connection. A typical full response for a 500 KB image with the same headers is ~500,400 bytes.
If origin handles 10,000 revalidations per second and 99% return 304 instead of 200, that converts ~5 GB/s of egress to ~3 MB/s of egress. The CPU and disk-read savings are similar — a 304 response usually involves a single metadata lookup, not a body read. ETag is the cheapest CDN-friendliness change you can make to an origin.
ETag and immutable assets
For build-versioned static assets (filenames like app.4f2a91.js), there is no need for ETag or revalidation at all. The URL changes whenever the content changes, so a cache that has any copy of app.4f2a91.js has the only correct version. The right pattern:
Cache-Control: public, max-age=31536000, immutable
immutable tells the cache (and the browser) to skip revalidation entirely. ETag still works if present but is redundant. For non-versioned static assets (e.g., /logo.png served at a stable URL), the opposite is true: ETag and conditional GET are how you keep revalidation cheap.
Frequently Asked Questions
What is an ETag?
An ETag (entity tag) is an opaque identifier the server attaches to a specific version of a resource. It is sent in the ETag response header. When a cache revalidates, it sends the stored ETag back in an If-None-Match request header; if the resource still has that ETag, the server responds 304 Not Modified with no body, saving bandwidth and CPU.
What is the difference between a strong and a weak ETag?
A strong ETag means the two representations are byte-for-byte identical and can be used for range requests and other byte-precise operations. A weak ETag (prefixed with W/) means the representations are semantically equivalent but may differ in trivial bytes (whitespace, compression, etc.). Range requests are not safe with weak validators; revalidation is fine with either.
What is a 304 Not Modified response?
A 304 is a response with no body that tells the cache its stored copy is still valid. The cache continues to serve its existing entry. 304s are tiny (a few hundred bytes of headers) compared to full responses, so they save substantial bandwidth and origin CPU when content has not changed.
Should I use ETag or Last-Modified?
Either works; both can be used simultaneously. Last-Modified has 1-second resolution and assumes wall-clock accuracy. ETag is opaque and can encode anything (content hash, version number, database row version). For static files, Last-Modified from the filesystem timestamp is fine. For dynamic content where multiple updates can happen in the same second, ETag based on a content hash is more reliable.
Why does my server return 200 instead of 304?
Usually because the validator the client sent does not match the validator the server now associates with the resource. Common causes: the server regenerates the ETag from a non-deterministic source (timestamp, random ID) so it changes even when content does not; the response is being recompressed and the ETag changes with each compression; the cache layer strips ETag headers; or the application framework doesn't implement 304 handling and always returns the full response.
Related Guides
More From This Section
All CDN & Edge Guides
How CDNs work, cache headers, anycast, edge functions, and security.
Anycast vs GeoDNS
Anycast and GeoDNS compared — how each routes users to CDN points of presence, BGP convergence, GeoDNS resolver…
Cache Hit Ratio Explained
What cache hit ratio actually measures, the difference between request and byte hit rate, and the configuration changes…
Run a Speed Test
Measure download, upload, ping, and jitter in your browser.