ETag vs Cache-Control: immutable for Fingerprinted Assets
When configuring HTTP caching for static assets, engineers face a concrete decision: rely on ETag revalidation to confirm a file hasn’t changed, or declare Cache-Control: immutable to eliminate revalidation entirely. For fingerprinted assets, these two mechanisms have meaningfully different performance and operational profiles. Choosing incorrectly introduces either unnecessary round-trips to your origin or unsafe caching for assets whose content can silently change at the same URL.
This guide walks through how each mechanism works, when each wins, and how to configure both correctly in Nginx, Cloudflare, and CloudFront.
How the Two Mechanisms Differ
ETag Revalidation
An ETag is an opaque token — typically a hash or modification timestamp — that the server attaches to a response. When a cached response expires, the browser sends a conditional request with the stored token:
GET /assets/main.a1b2c3d4.js HTTP/1.1
If-None-Match: "a1b2c3d4"
If the content is unchanged, the server returns 304 Not Modified with no body, saving bandwidth. If the content changed, the server returns 200 with the new body and a new ETag. Either way, a network round-trip to the CDN edge or origin is required before the browser can use the cached copy.
This round-trip is the key cost. Even though a 304 sends no body, it still consumes latency: DNS, TCP (or TLS resumption), and the server processing time. On a mobile connection or under high concurrency, hundreds of revalidation requests per page load accumulate.
Cache-Control: immutable
The immutable directive tells the browser not to attempt revalidation during the max-age window, even when the user manually refreshes the page. No conditional request is sent. The browser uses the cached copy unconditionally.
HTTP/1.1 200 OK
Cache-Control: public, max-age=31536000, immutable
ETag: "a1b2c3d4"
This works safely for fingerprinted assets because the filename itself is the content fingerprint (see content hashing vs semantic versioning for how build tools generate these). If /assets/main.a1b2c3d4.js is cached and valid, the content at that URL is immutably defined by the hash in the name. There is nothing to revalidate — if the content changed, the build produced a different hash and a different URL.
The result: zero round-trips for returning visitors during the max-age window. The browser resolves assets entirely from its local cache.
Request Flow: Side-by-Side
The diagram below shows the difference in network activity for a returning visitor requesting a fingerprinted asset. The ETag path (left) requires a round-trip even on a cache hit. The immutable path (right) serves directly from the browser cache.
immutable skips the network entirely during the max-age window.Comparison Table
| ETag Revalidation | Cache-Control: immutable | |
|---|---|---|
| Mechanism | Browser sends If-None-Match; server confirms with 304 or returns 200 |
Browser skips the request entirely during max-age |
| Round-trips on cache hit | 1 (to CDN edge or origin) | 0 |
| Bandwidth on cache hit | Headers only (304, no body) | None |
| Origin/CDN load | Every expired cache entry triggers a revalidation request | No requests during max-age window |
| Browser support | Universal | Firefox 49+, Chrome 49+, Safari 12.1+; ignored (safely) by older browsers |
| Safe without fingerprinting? | Yes — server controls validity | No — content change at the same URL breaks caching guarantees |
| Best for | HTML entry points, API responses, non-hashed assets | Fingerprinted JS, CSS, images, fonts with cache-key architecture |
When ETag Still Matters
Cache-Control: immutable does not eliminate ETags from your stack. Several cases still require revalidation:
HTML entry points. Your index.html or server-rendered pages are not fingerprinted — the URL doesn’t change between deploys. Serve these with Cache-Control: no-cache (forces revalidation on every request) plus a content-based ETag. The browser will always revalidate, but gets a fast 304 when the HTML hasn’t changed.
API responses. Dynamic responses change based on data, not a build hash. ETags let clients skip re-parsing identical payloads.
Non-hashed assets. Any file served at a stable URL — favicons, robots.txt, Open Graph images — cannot use immutable. Use ETag or Last-Modified so clients can revalidate efficiently.
Service worker update flow. The service worker file itself (sw.js) must always be fetched fresh. Serve it with Cache-Control: no-cache and rely on byte-level comparison (which browsers perform automatically) rather than ETags.
Where the ETag Actually Comes From
An ETag is opaque to the client but not to you, and knowing how your origin derives it explains most revalidation failures. The three common origins produce three very different values.
Nginx builds the ETag from the file’s last-modified time and its content length, formatted as two hex fields. That makes it a cheap validator with an awkward property: a deploy that rewrites byte-identical files with fresh timestamps changes every ETag on the server, so a warm client’s If-None-Match misses and receives a full 200 with a body it already had. Apache 2.4 defaults to FileETag MTime Size, which behaves the same way; older configurations that still list INode produce a value that is different on every machine in a fleet, because the inode number is a property of that machine’s filesystem. Amazon S3 uses the MD5 digest of the object for a single-part upload — genuinely content-derived, and stable across re-uploads — but for a multipart upload it returns the digest of the concatenated part digests followed by a dash and the part count, which changes if the same bytes are uploaded with a different part size.
Compression complicates this further. A response body transformed on the fly is no longer byte-identical to the stored representation, so Nginx drops the ETag when it gzips a response it did not have pre-compressed, and Apache historically appended a -gzip suffix. A CDN that recompresses at the edge can produce the same mismatch. If your revalidation traffic looks suspiciously like full 200 responses, compare the ETag on a compressed and an uncompressed fetch of the same URL before blaming the browser. None of this touches a fingerprinted asset served with immutable, which is precisely the argument for the directive: the URL is already the validator, so a fragile server-side one cannot cost you anything.
When immutable Still Needs ETag Alongside It
Serving Cache-Control: immutable on fingerprinted assets does not mean you should strip ETags. Two scenarios justify keeping both headers together:
CDN-to-origin validation. Even when the browser never revalidates, a CDN edge node may need to revalidate with the origin after its own TTL expires. If the origin responds with the same ETag, the CDN can serve its cached copy without fetching the full body. This internal CDN-to-origin 304 saves egress bandwidth on your origin, independently of what the browser does.
Range requests. Clients (especially for video or large binary assets) may request byte ranges. ETags are required to validate range requests correctly — a mismatched ETag signals that the file changed between partial fetches, preventing data corruption.
One Contract per URL Class
The decision is not “ETag or immutable” for a site; it is a per-URL-class question answered by one property: does this URL always mean the same bytes? Three classes cover almost every path a real deployment serves, and each gets a fixed header recipe.
The middle row is where most misconfiguration lives, because no-cache reads like “do not store” and means the opposite: store it, but revalidate before reuse. Choosing the number in the bottom row is its own trade-off, worked through under choosing max-age for hashed versus unhashed assets.
Configuration
Nginx
Serve fingerprinted assets with both immutable and ETag enabled. Serve HTML with ETag only and no immutable. The regex matches URLs containing an 8-character hex segment — the naming convention produced by most bundlers with [contenthash:8].
# Fingerprinted static assets: immutable + ETag for CDN-to-origin validation
location ~* \.(js|css|woff2|png|jpg|svg|ico)$ {
root /var/www/dist;
etag on;
# Match 8–16 hex chars in the filename (adjust for monorepo builds)
if ($request_uri ~ "\.[a-f0-9]{8,16}\.") {
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
}
# Fallback for non-fingerprinted static files
add_header Cache-Control "public, max-age=3600, must-revalidate";
}
# HTML entry points: always revalidate, never immutable
location ~* \.html$ {
root /var/www/dist;
etag on;
add_header Cache-Control "no-cache";
}
Note: use [contenthash:12] or [contenthash:16] in your bundler config and widen the regex upper bound for monorepos where collision risk increases with asset volume.
Cloudflare Cache Rule
In the Cloudflare dashboard, navigate to Caching → Cache Rules and create a rule that targets fingerprinted asset paths and appends immutable to the Cache-Control header:
Rule name: Immutable fingerprinted assets
When: URI Path matches regex
^/assets/.*\.[a-f0-9]{8,16}\.(js|css|woff2|png|jpg|svg)$
Then:
Cache eligibility: Eligible for cache
Edge TTL: Override, 365 days
Browser TTL: Override, 365 days
Response headers → Add header:
Cache-Control: public, max-age=31536000, immutable
Cloudflare strips immutable from its own cache key logic but forwards it to the browser. The edge still uses ETags internally for origin revalidation when its own TTL lapses. For detailed TTL tuning strategies, see Cache-Control: immutable and TTL tuning.
AWS CloudFront
In a CloudFront behavior for your fingerprinted asset path pattern (e.g., /assets/*):
- Compress objects automatically: Yes
- Cache policy: Create a custom policy with TTL min/default/max all set to 31536000 (one year)
- Origin request policy: Do not forward cookies or query strings for static assets
- Response headers policy: Add a custom header:
Cache-Control: public, max-age=31536000, immutable
CloudFront does not forward the browser’s If-None-Match to the origin by default when the edge has a cached copy, making it naturally aligned with the immutable pattern for the browser layer. The origin should still emit ETags for CloudFront’s own internal revalidation.
Verification
Confirm that a returning browser request for a fingerprinted asset produces zero network activity. First, request the asset cold to populate the cache:
# Initial request — should return 200 with immutable header
curl -sI https://cdn.example.com/assets/main.a1b2c3d4.js \
| grep -iE "cache-control|etag|cf-cache-status|x-cache"
Expected output:
cache-control: public, max-age=31536000, immutable
etag: "a1b2c3d4"
cf-cache-status: MISS
On a second request from the same curl session (simulating a warm CDN edge):
curl -sI https://cdn.example.com/assets/main.a1b2c3d4.js \
| grep -iE "cache-control|etag|cf-cache-status|age"
Expected output:
cache-control: public, max-age=31536000, immutable
etag: "a1b2c3d4"
cf-cache-status: HIT
age: 14
The age: 14 line is the confirmation that the second response came out of the edge store rather than the origin; reading it against date is how you attribute a response to a specific cache tier, covered in the guide to Age and Date headers for edge cache debugging.
To verify no If-None-Match is sent by the browser, open Chrome DevTools → Network tab, load a page with a fingerprinted asset, then hard-reload (Shift+Reload). With immutable, the asset row should show (from disk cache) with no request sent to the network. Without immutable, the browser sends a conditional request and you’ll see a 304 response.
When to Reconsider
If you do not use filename fingerprinting, you cannot safely use Cache-Control: immutable. The immutable directive works only because the URL is a content identity. If your build process serves the same URL (/assets/main.js) for different content across deploys — using query strings, manual version numbers, or no versioning at all — immutable will lock browsers into a stale copy for up to a year with no recourse. Users cannot bypass it without clearing their browser cache manually.
Switch back to ETag revalidation (with a shorter max-age) in these situations:
- Assets served at stable URLs without content hashes
- Environments where the build pipeline cannot be updated to emit hashed filenames
- A/B tested assets where the same URL intentionally serves different content to different users
- Emergency hotfix scenarios where you must update a file at an existing URL immediately — even a CDN purge will not help if browsers have already cached
immutablecopies
If you find yourself in the last case, the only reliable fix is to change the URL (which means deploying a new filename), wait for the max-age to expire, or instruct users to clear their cache. This is why immutable must be reserved strictly for fingerprinted assets, and why the fingerprinting strategy itself is the foundational prerequisite.
Frequently Asked Questions
Should I disable ETags entirely once assets are immutable?
No. Turning ETags off saves a few dozen bytes per response and costs you the CDN’s ability to revalidate cheaply with your origin when its own edge TTL lapses. Keep them emitted; the browser simply never uses them on a URL marked immutable. The one case for disabling is a load-balanced Apache fleet still configured with FileETag INode, where the ETag is actively harmful — fix the directive rather than removing the header.
What is the difference between a weak and a strong ETag?
A strong ETag, written bare as "a1b2c3d4", asserts that two responses carrying it are byte-identical. A weak one, prefixed W/, asserts only that they are semantically equivalent — the same document, possibly recompressed or reformatted. Range requests require a strong validator, which is why a server that recompresses on the fly must either weaken or drop the ETag rather than reuse the original.
Does immutable do anything on a normal page reload?
Yes, and that is the point of the directive. Without it, a reload prompts the browser to revalidate every subresource even when their max-age has not expired, which is how a returning visitor generates dozens of 304 responses on a page they loaded five minutes earlier. immutable tells the browser that a reload is not a reason to re-check. A hard reload with the cache disabled still bypasses everything, as it should.
Can I set immutable at the CDN instead of the origin?
You can, and for a provider-managed edge it is often the simpler place to do it — a response-headers policy or cache rule appends the directive without touching your server config. Be aware that the header the browser stores is then produced by the edge, so a request that bypasses the CDN during an incident receives the origin’s weaker directive instead. Emitting the correct value from the origin and letting the edge pass it through avoids that divergence entirely.
Related
- Fingerprinting in HTTP Headers — up: the parent guide covering ETag generation, cache directives, and header-based fingerprinting workflows
- Cache Key Architecture — how CDNs construct cache keys and why filename fingerprinting aligns better than query strings
- Cache-Control: immutable and TTL Tuning — advanced TTL strategies, CDN-layer immutable configuration, and purge workflows
- Content Hashing vs Semantic Versioning — why content-derived hashes are the prerequisite for safe immutable caching
- Best Practices for Static Asset Naming Conventions — naming patterns that make regex-based immutable header rules reliable