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.

ETag revalidation versus immutable Two side-by-side sequence diagrams. On the left the browser sends a conditional request that travels to the CDN edge and the origin and returns a 304. On the right the browser serves the asset from disk without contacting either. ETag revalidation Cache-Control: immutable Browser CDN edge Origin If-None-Match token forward revalidation 304 Not Modified 304 relayed to client use cache One round-trip before first byte latency paid even though nothing changed Browser CDN edge Origin max-age still valid? yes, skip the request no request sent not contacted served from disk cache Zero round-trips the network is never touched
Left: ETag revalidation requires a browser to edge to origin round-trip even when content is unchanged. Right: 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.

What an ETag is derived from Nginx derives the ETag from modification time and content length, Apache from modification time and size unless inode is configured, and S3 from the object digest with a part count suffix for multipart uploads. Below, two load-balanced nodes emit different ETags for the same file, so the conditional request misses and the client receives a full 200 instead of a 304. What an ETag is actually derived from Nginx mtime plus content length 6486f2a1-3b7c moves when you rsync Apache 2.4 FileETag MTime Size add INode and it differs per server Amazon S3 digest of the object multipart appends a dash and the part count The load-balanced failure node A ETag 6486f2a1-3b7c node B ETag 6486f2b9-3b7c If-None-Match misses full 200 instead of 304
Two servers, one file, two validators — the revalidation saving evaporates and the client pays for a body it already holds.

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.

One contract per URL class Fingerprinted assets get a one-year immutable directive with an ETag retained for edge-to-origin revalidation. HTML entry points get no-cache with a content-derived ETag so every request revalidates cheaply. Unhashed static files get a short max-age with a required ETag and must never be marked immutable. One contract per URL class Fingerprinted asset /assets/app.3f8d2a1c.js Cache-Control: public, max-age=31536000, immutable ETag kept for edge-to-origin revalidation only zero browser round-trips for a year HTML entry point / and /index.html Cache-Control: no-cache ETag content-derived, revalidated on every request a fast 304 when the document has not changed Unhashed static file /favicon.ico, /robots.txt Cache-Control: public, max-age=3600 ETag required, because the URL outlives its bytes never immutable at a reused URL
Sort every path into one of these three rows and the ETag question answers itself for the whole site.

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 immutable copies

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.