Skip to main content
Web

ETag and 304: how conditional requests revalidate a cache without re-downloading

If-None-Match, If-Modified-Since, weak vs strong ETags, and why your 304s never fire behind a CDN. The revalidation half of HTTP caching, with real request and response traces.

Thien
By Thien
Updated October 10, 2026 · 4 min read
ethernet cables plugged into a network switch panel in a data center

A conditional request is a normal GET with a question attached: "send the body only if it changed." The client sends the validator it stored (If-None-Match: "abc" or If-Modified-Since: <date>); if it still matches, the server replies 304 Not Modified with no body, and the client reuses its cached copy. You save the transfer, not the round trip.

The Cache-Control post covered when a cache may skip the network entirely. This one covers what happens after max-age runs out, or when you send no-cache: the revalidation request. It's where most "why is my 304 a 200?" bugs live.

The whole exchange, on the wire

First request. The server attaches a validator:

GET /app.css HTTP/1.1
Host: example.com

HTTP/1.1 200 OK
Cache-Control: max-age=60
ETag: "33a64df5"
Last-Modified: Tue, 06 Oct 2026 09:12:00 GMT
Content-Length: 48211

Sixty seconds later the copy is stale, so the browser asks instead of downloading:

GET /app.css HTTP/1.1
Host: example.com
If-None-Match: "33a64df5"
If-Modified-Since: Tue, 06 Oct 2026 09:12:00 GMT

HTTP/1.1 304 Not Modified
ETag: "33a64df5"
Cache-Control: max-age=60

48 KB became a few hundred bytes of headers. Note the 304 also carries fresh Cache-Control, which resets the freshness clock on the stored copy. That's why a 304 is useful even when the body never changes.

ETag vs Last-Modified

ETagLast-Modified
ResolutionOpaque string, any granularityOne second
Request headerIf-None-MatchIf-Modified-Since
Breaks whenGenerated differently per serverContent changes twice within a second, or file mtime changes with identical bytes (a redeploy)
Wins if both sentYes — RFC 9110 says If-Modified-Since is ignored when If-None-Match is presentNo

The redeploy case is the common one. Docker builds and git checkout reset every file's mtime, so Last-Modified changes while the bytes don't, and every client re-downloads files that are identical. Send an ETag derived from content and this stops.

Strong vs weak ETags

"33a64df5" is strong: byte-for-byte identical. W/"33a64df5" is weak: semantically equivalent, bytes may differ. For If-None-Match revalidation, caches use weak comparison, so either form works. Strong matters for range requests and If-Match, which need exact bytes.

The practical gotcha: compression. Many servers and CDNs weaken the ETag (nginx prepends W/) or strip it when gzip or brotli is applied on the fly, because the compressed bytes differ per encoding. If your 304s vanish only for compressed assets, check the ETag in the response, not the code.

Why your 304 never fires

  1. Per-server ETags. Apache's default ETag historically mixed in the inode; two servers behind a load balancer produced different ETags for the same file, so every other request missed. Derive the tag from content (a hash), never from filesystem metadata.
  2. Quote marks. The value includes the double quotes. ETag: 33a64df5 without them is invalid, and a client may never send it back.
  3. no-store. The browser doesn't keep the body, so there's nothing to revalidate. You get a plain 200 every time.
  4. A CDN that rewrites the response. Image optimizers and minifiers change the body but may forward the origin ETag, or drop it. Compare headers at the origin and at the edge.
  5. Dynamic responses with no validator. An API handler that never computes an ETag can't 304. Hashing the serialized body is cheap compared with sending it.

Generating an ETag for a dynamic response

The server still builds the response; the saving is bandwidth, not CPU. That's usually the right trade for large JSON.

import { createHash } from "node:crypto";

export function respond(req: Request, body: string): Response {
  const etag = `"${createHash("sha1").update(body).digest("base64url").slice(0, 16)}"`;
  const headers = { ETag: etag, "Cache-Control": "no-cache" };
  const sent = req.headers.get("if-none-match");
  if (sent && sent.split(",").some((t) => t.trim().replace(/^W\//, "") === etag)) {
    return new Response(null, { status: 304, headers });
  }
  return new Response(body, { headers });
}

Two details people miss: If-None-Match can hold a comma-separated list (or *), and the 304 must repeat the ETag header. Cache-Control: no-cache here is deliberate. It means "store it, but revalidate every time," which is exactly the contract an ETag serves. If you're composing the header, the Cache-Control header builder helps you check the directive combination.

Don't ETag what you can fingerprint

For hashed build assets (app.3f9a1c.js), skip revalidation entirely: Cache-Control: public, max-age=31536000, immutable. A 304 is still a round trip; immutable is none. ETags earn their keep on URLs that can't change name: HTML documents, API responses, unversioned images.

FAQ

Does a 304 cost anything?

One round trip and a few hundred bytes of headers. On high-latency mobile links that round trip is the real cost, which is why long max-age plus fingerprinted names beats revalidation.

Can I use ETag for optimistic locking?

Yes, with If-Match on PUT or PATCH: the server returns 412 Precondition Failed if the resource changed. It requires strong ETags.

Why does a hard refresh skip my 304?

Reload sends Cache-Control: max-age=0 on the request, and a hard reload bypasses the cache outright. DevTools "Disable cache" does the same, so test 304s with a normal navigation.

The short version: put a content-derived ETag on anything that can't be fingerprinted, make sure it survives compression and your CDN, and let no-cache do the rest.

References

Primary documentation and specifications checked when this article was last updated.

WebHTTPPerformance

Related articles

All articles