# ETag Header: Strong vs Weak, If-None-Match and 304 Debugging

> ETag identifies a resource version for cache validation and optimistic locking. Strong vs weak ETags, If-None-Match, If-Match, and why nginx gzip weakens them.

Source: https://howhttpworks.com/headers/etag
Last reviewed: 2026-10-05

> **TL;DR:** `ETag` is a version fingerprint for a response. Clients send it back as `If-None-Match` to get a bodiless `304 Not Modified`, or as `If-Match` to refuse writes to a changed resource (`412`). If you never see 304, your ETags are unstable.

## How it works

```http
GET /app.css HTTP/1.1

HTTP/1.1 200 OK
Cache-Control: no-cache
ETag: "33a64df5"
Content-Length: 18240
```

Later, the client revalidates:

```http
GET /app.css HTTP/1.1
If-None-Match: "33a64df5"

HTTP/1.1 304 Not Modified
ETag: "33a64df5"
Cache-Control: no-cache
```

The 304 has no body; the cache reuses its stored copy and updates the headers. The quotes are part of the value. Defined in RFC 9110 section 8.8.3 and used by caches per RFC 9111 section 4.3.

## Strong and weak

```http
ETag: "33a64df5"
ETag: W/"33a64df5"
```

Strong: byte-for-byte identical. Weak: equivalent for the application's purposes. Comparison rules (RFC 9110 section 8.8.3.2): `If-None-Match` uses weak comparison, while `If-Match` and `If-Range` need strong. That means a weak ETag works for cache revalidation but cannot support safe writes or resuming a partial download.

### Why gzip makes your ETag weak

When nginx compresses a response on the fly, it rewrites a strong ETag into a weak one (`W/"..."`) because the compressed body is a different byte sequence. Apache's `mod_deflate` historically appended `-gzip` to the tag (`"33a64df5-gzip"`), which broke `If-None-Match` matches on the uncompressed variant in some versions. Both are harmless for caching but defeat `If-Range`. Options: serve precompressed files (`gzip_static on;`, Brotli equivalents) with their own strong tags, or accept weak tags.

```nginx
etag on;          # default for static files; derived from mtime and size
gzip_static on;   # serve app.css.gz as-is with a stable ETag
```

## Generating ETags

- Hash of the content: stable across servers, costs CPU on dynamic content. Express generates weak ETags by default via `app.set('etag', 'weak')`.
- Version or revision number from the database row: cheap and strong semantics for APIs (`"v17"`).
- File mtime plus size (nginx default): fine on a single node. nginx's format is `"<mtime hex>-<size hex>"`.
- Avoid inode-based ETags behind a load balancer. Apache 2.4 defaults to `FileETag MTime Size`; if a config still enables `INode` (the old Apache 2.2 default), remove it and check that file modification times agree across nodes.

## Optimistic concurrency with If-Match

```http
PUT /api/docs/7 HTTP/1.1
If-Match: "v17"
Content-Type: application/json

{"title":"Updated"}
```

```http
HTTP/1.1 412 Precondition Failed
ETag: "v18"
```

Someone else saved v18 first. Return `428 Precondition Required` if you demand the header and it is missing. See [If-Match](https://howhttpworks.com/headers/if-match), [412](https://howhttpworks.com/status-codes/412) and [428](https://howhttpworks.com/status-codes/428).

## Debug "no 304"

```bash
curl -sI https://example.com/app.css | grep -i etag
curl -sI https://example.com/app.css | grep -i etag   # same value?
curl -si https://example.com/app.css -H 'If-None-Match: "33a64df5"' | head -1
```

If the value changes between calls, you are hitting different origins or a layer rewrites it. If it is stable but the second command returns 200, the server does not implement conditional handling or `Vary` differs. A CDN that does not forward `If-None-Match` to the origin can still answer 304 itself from its cache, and Cloudflare may drop strong ETags on compressed responses, so check `cf-cache-status` and compare ETags at the origin directly.

Also note an ETag does nothing for freshness; pair it with [Cache-Control](https://howhttpworks.com/headers/cache-control) `no-cache` or `max-age`.

## Related

- [If-None-Match](https://howhttpworks.com/headers/if-none-match), [If-Match](https://howhttpworks.com/headers/if-match), [If-Range](https://howhttpworks.com/headers/if-range), [Last-Modified](https://howhttpworks.com/headers/last-modified), [Vary](https://howhttpworks.com/headers/vary)
- [304 Not Modified](https://howhttpworks.com/status-codes/304) and [304 vs 200](https://howhttpworks.com/compare/304-vs-200)
