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.
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.
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.
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
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.
Related
- Fingerprinting in HTTP Headers — parent cluster 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