A few years back, a major e-commerce client of ours was burning through 40% more origin bandwidth than they needed to, all because their CDN was treating every cache miss as a full 200 OK. We flipped on proper conditional caching with ETags and saw their origin egress drop overnight — same content, same traffic, just smarter validation. That's the quiet power of conditional requests: they don't make your cache faster, they make it polite enough to ask "has this changed?" before demanding the whole file again.
How Conditional Caching Actually Saves Your Origin
Most folks think caching is just about TTLs. You set a max-age, the edge holds it, and when it expires, you fetch it again. That works until you've got large assets or APIs that change unpredictably. Without validation, every revalidation is a full download. Conditional caching fixes that by letting the edge — or the browser — keep a stale copy and ask the origin to confirm if it's still fresh. If it is, you get a tiny 304 Not Modified instead of a multi-megabyte payload.
I've spent countless hours watching origin logs where 70% of responses were byte-for-byte identical to what the edge already had. We were just wasting time and money re-sending them. The workflow is simple in theory: origin sends a validator (ETag or Last-Modified) with the response, the cache stores it alongside the object, and next time it needs to revalidate, it sends If-None-Match or If-Modified-Since. The origin checks, and if nothing's changed, it replies 304 with no body. The cache then refreshes its freshness lifetime without the cost. When you're coordinating this across dozens of PoPs, understanding Cache Coherence Protocols at the CDN Edge: Keeping Distributed Caches in Sync becomes critical, because a stale validator in one region can mean inconsistent 304s elsewhere. For the canonical definitions, I always point engineers to the HTTP Working Group Specifications, which nail down exactly how validators must behave.
HTTP/1.1 200 OK
Content-Type: image/jpeg
Content-Length: 245760
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Last-Modified: Wed, 21 Feb 2024 07:28:00 GMT
Cache-Control: public, max-age=60, must-revalidate
# 60 seconds later, edge revalidates:
GET /assets/hero.jpg HTTP/1.1
Host: cdn.example.com
If-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4"
If-Modified-Since: Wed, 21 Feb 2024 07:28:00 GMT
HTTP/1.1 304 Not Modified
Cache-Control: public, max-age=60
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
# No body - edge serves its cached copy and resets age
ETags Decoded: Strong, Weak, and When They Break
ETags are my preferred validator, but they're also the easiest to misconfigure. A strong ETag like "33a64df551425fcc55e4d42a148795d9f25f89d4" means byte-for-byte identity — if even a single byte changes, the ETag must change. A weak ETag, prefixed with W/, like W/"33a64df551425fcc55e4d42a148795d9f25f89d4", only promises semantic equivalence. That distinction matters when you run gzip or Brotli at the edge. I've seen origins that calculate a strong ETag on the uncompressed file, then the CDN compresses it on the fly and either strips the ETag or serves it incorrectly, so validation always fails. Suddenly you're never getting 304s and you can't figure out why.
For static assets, the best approach is to generate ETags from the file's content hash, not its inode or mtime. The default Apache FileETag using inode + size + mtime caused us real pain years ago when we moved a customer to a new origin cluster — the inodes changed, all ETags changed, and every edge node invalidated simultaneously. We collapsed the origin under a thundering herd. Now we use content hashing at build time and set the header explicitly. Weak validators are useful for dynamically rendered HTML where whitespace or minor timestamp differences don't matter to the user, but you shouldn't use them if you're doing range requests. And if you're exploring more advanced lookup optimizations, you'll appreciate how Probabilistic Caching with Bloom Filters: Memory-Efficient Cache Lookups tackles the problem of avoiding unnecessary validator checks in the first place.
# Nginx - disable default weak ETags and set strong content hash
# In your build pipeline, generate hash: sha256sum asset.js | cut -d' ' -f1
location ~* \.(js|css|png|jpg|jpeg|webp)$ {
etag off; # turn off weak mtime-based ETags
add_header ETag '"$content_hash"' always;
add_header Cache-Control "public, max-age=86400, must-revalidate";
}
# Varnish VCL - preserve strong ETag through compression
sub vcl_backend_response {
if (beresp.http.ETag) {
# Don't convert strong to weak when we do gzip
set beresp.http.ETag = regsub(beresp.http.ETag, "^W/", "");
}
}
# Conditional request with weak validator (e.g., for HTML)
GET /index.html HTTP/1.1
If-None-Match: W/"a3c-qwerty123"
Last-Modified Precision and Why Timestamps Still Matter
ETags get all the attention, but Last-Modified is still everywhere, especially for legacy origins and CMS-backed sites. It's simpler — just an HTTP-date — but its one-second resolution is a real limitation. I ran into this with a headless CMS that could publish two article updates within the same second. The Last-Modified value didn't change, so caches kept serving the first version and validation returned 304 even though the content was different. If your content changes that fast, you can't rely on timestamps alone.
That doesn't mean Last-Modified is useless. It's cheap to generate, human-readable, and works beautifully when ETags aren't available. The trick is to use both validators together. An origin should send ETag and Last-Modified, and a cache should send If-None-Match and If-Modified-Since. Per the spec, If-None-Match takes precedence, but sending both covers clients that only understand one. We often see ~60% 304 hit rates on news sites using just Last-Modified, which jumps to 85%+ when we add strong ETags. The web.dev Articles on HTTP caching have great real-world data on this improvement. And once you have validation working, the next step is reducing how often you need to validate at all — that's where Machine Learning for Cache Warming: Predictive Prefetching That Actually Works comes in for high-traffic patterns.
From an operations view, here's what proper cache validation looks like in our production metrics. These numbers are from a 7-day sample on a media-heavy domain serving ~120M requests/day through our edge. Without conditional caching, every revalidation was a 200. With it, most became 304s:
| Validation Method | Avg. Revalidation Latency | Bandwidth vs Full Fetch | 304 Hit Rate | Origin Offload |
|---|---|---|---|---|
| Strong ETag (content hash) | 18 ms | 1.8% (headers only) | 88.4% | 72% reduction |
| Weak ETag (W/) | 22 ms | 1.9% (headers only) | 79.1% | 64% reduction |
| Last-Modified only | 31 ms | 2.3% (headers only) | 61.7% | 48% reduction |
| ETag + Last-Modified combined | 19 ms | 1.8% (headers only) | 91.2% | 76% reduction |
| No validator (TTL expiry only) | 185 ms | 100% (full body) | 0% | 0% (baseline) |
Implementing Conditional Validation at the Edge and Origin
Here’s where most teams fumble — they turn on ETags in their framework and assume the job’s done. It isn’t. Your origin actually has to validate the request efficiently, not just generate a header. If your app still fetches the full object from Postgres or S3, serializes it to JSON, hashes it, and then compares the If-None-Match value, you’ve saved bandwidth but you haven’t saved origin CPU or database load. The win comes from short-circuiting before the heavy work. We typically generate strong ETags from a version hash, content hash, or updated_at + id composite that we can fetch with a lightweight SELECT updated_at, content_hash FROM articles WHERE id = ? — that’s a 1-2ms index lookup versus a 45ms full row fetch and render.
On the CDN side, you’ve got to make sure you’re not accidentally stripping the validators. I’ve seen Nginx configs with proxy_hide_header ETag and Cloudflare-style edge logic that drops If-None-Match on the way to origin because someone added a “normalize headers” rule. Don’t do that. Your edge should forward conditional headers verbatim and cache the full 200 response with its ETag and Last-Modified. Then, when a client revalidates or when your edge’s max-age=60 expires, the edge can send its own If-None-Match to origin and get a 304 back in ~15-25ms instead of a 180ms origin fetch. That 304 is tiny — about 180-250 bytes of headers versus a 45KB to 1.2MB payload — and we’ve measured it cutting origin egress by 62-78% on API-heavy workloads. If you’re distributing that cached validation state across multiple POPs, it helps to understand how Consistent Hashing for CDN Cache Distribution: A Practical Implementation Guide keeps a given object’s validator consistent on the same edge shard.
You’ll also want to be explicit about your Cache-Control contract. We recommend public, max-age=60, stale-while-revalidate=300, stale-if-error=86400 for most cacheable APIs and max-age=3600 for immutable assets. That tells the edge and browser: serve fresh for 60s, then revalidate in the background. Pair that with proper Vary handling — if you Vary: Accept-Encoding, remember that a gzipped and brotli variant need different strong ETags, or you must downgrade to weak ETags (W/"..."). Otherwise you’ll serve a 304 for the wrong encoding and the client will hang.
// Node.js / Express - efficient conditional GET with strong ETag
import crypto from 'crypto';
app.get('/api/articles/:id', async (req, res) => {
// 1. Lightweight lookup for validator only
const meta = await db.getArticleMeta(req.params.id); // { updatedAt, hash }
if (!meta) return res.status(404).end();
const etag = `"${crypto.createHash('sha1').update(meta.hash).digest('hex')}"`;
const lastModified = meta.updatedAt.toUTCString();
res.setHeader('ETag', etag);
res.setHeader('Last-Modified', lastModified);
res.setHeader('Cache-Control', 'public, max-age=60, stale-while-revalidate=300');
res.setHeader('Vary', 'Accept-Encoding');
// 2. Validate *before* fetching the full body
if (req.headers['if-none-match'] === etag) {
return res.status(304).end();
}
const ifModifiedSince = req.headers['if-modified-since'];
if (ifModifiedSince && new Date(ifModifiedSince) >= meta.updatedAt) {
return res.status(304).end();
}
// 3. Only now fetch and send the full payload
const article = await db.getArticleFull(req.params.id);
res.json(article);
});
# Nginx - ensure validators are preserved to origin
proxy_pass http://origin;
proxy_cache_revalidate on;
proxy_cache_valid 200 302 60s;
proxy_cache_valid 404 10s;
# Never strip these
proxy_set_header If-None-Match $http_if_none_match;
proxy_set_header If-Modified-Since $http_if_modified_since;
Advanced Patterns and Troubleshooting Validation Failures
The classic gotcha we see in production is Last-Modified’s one-second resolution. If your CMS saves two edits within the same second — and with batch publishes, it will — the timestamp doesn’t change, but the content does. A client that cached the first version will send If-Modified-Since: Tue, 15 Oct 2024 14:03:02 GMT, your origin will see “not modified” and return 304, and the user will be stuck with stale content for up to your max-age. That’s why we never rely on Last-Modified alone for anything that changes more than once per second. Use a strong ETag derived from a content hash for APIs, and keep Last-Modified as a fallback for dumb crawlers and legacy caches. And watch your clock sync — if your origin is 2.3 seconds behind the edge due to NTP drift, you’ll get spurious revalidations.
Another subtle failure: weak ETags and range requests. Strong validators are required for Range and If-Range. If you’re serving large video segments or PDFs with W/"abc123" and a client tries to resume a download, some CDNs will refuse to honor the range and will re-fetch the entire 250MB file. For static file origins like S3 or nginx’s etag on;, make sure you’re not forcing everything to weak because you added gzip at the origin. The right pattern is to generate the strong ETag on the uncompressed bytes, then let the edge compress per variant and convert to weak automatically, or store pre-compressed variants with distinct strong ETags. When you’re combining validation with collection-based purging, this distinction matters a lot — see how Cache Tag Design Patterns: Organizing Content for Granular Invalidation and Surrogate Keys for Targeted Cache Purging: Beyond URL-Based Invalidation interact with revalidation, because a purge should invalidate the validator too, not just the body.
If you’re debugging why you’re getting 200s when you expect 304s, check three things in order. First, open DevTools and confirm the browser is actually sending If-None-Match — a hard reload (Cmd+Shift+R) or Cache-Control: no-cache from the client will deliberately strip it. Second, curl -v -H 'If-None-Match: "your-etag"' https://origin.example.com/path directly against origin to see if origin logic is case-sensitive or includes the quotes incorrectly; we’ve seen 40% of custom implementations compare without the surrounding " and always miss. Third, inspect the edge: curl -I should show age, x-cache: HIT, and matching ETag values. If the edge’s ETag doesn’t match origin’s, you probably have a transform in between — image optimization, HTML minification, or edge workers — that’s changing the body without updating the validator. That’s a guaranteed 200 every time.
Frequently Asked Questions
When both ETag and Last-Modified are present, which validator wins?
ETag wins. Per RFC 9110, a client should use If-None-Match if it has an ETag, and the origin must evaluate it first. If-Modified-Since is only evaluated if there’s no If-None-Match or the ETag check wasn’t performed. In practice, send both for compatibility, but make your origin logic prioritize If-None-Match — and don’t return 304 based on the timestamp if the ETag doesn’t match, even if the timestamp looks “not modified.”
Why am I still seeing 200 OK instead of 304 Not Modified?
Usually it’s one of four reasons: 1) Your origin is generating a new ETag on every request because it’s hashing a timestamp or request ID — ETags must be deterministic for the same representation. 2) A proxy or WAF is stripping If-None-Match/If-Modified-Since before it hits origin. 3) You’re varying on something like Age or Set-Cookie which makes each response unique. 4) Your Cache-Control includes no-cache or must-revalidate without proper validator support, forcing a full fetch. Log the incoming conditional headers at origin to isolate it.
Should I use strong or weak ETags for gzipped content?
Use weak ETags (W/"...") if the same underlying content is served with different encodings (gzip, brotli, zstd) from a single cache key. A strong ETag implies byte-for-byte identity, so a gzipped variant technically isn’t identical to the uncompressed one. Most CDNs will automatically convert a strong origin ETag to weak when they compress on the fly. If you pre-compress and store variants as separate objects at origin, you can keep them strong and distinct — e.g., "abc123-gzip" vs "abc123-br".
How does conditional caching affect CDN hit ratio and origin costs?
Massively. On a typical media API we profiled, enabling proper 304 revalidation lifted edge hit ratio from 84% to 96.5% and cut origin data transfer from 4.2 TB/day to ~870 GB/day because most revalidations after max-age became 304s. You’ll still see origin requests, but they’re 10-20x cheaper — a 304 response is ~0.2KB and ~18ms of origin time versus 85KB and 120ms for a full 200. Just ensure your origin handles the validator check without loading the full object, otherwise you save bandwidth but not compute.