HTTP 304 vs 200 — Not Modified vs OK (Caching) Comparison
http 304 vs 200: 304 is sent when a conditional GET hits an unchanged resource, saving bandwidth. Learn ETag, If-None-Match, and how 304 cuts CDN/origin costs.
Quick Cheat Sheet
| Aspect | 304 Not Modified | 200 OK |
|---|---|---|
| Has response body? | No (just headers) | Yes |
| Triggered by | Conditional headers (If-None-Match, If-Modified-Since) |
Anything else |
| Bandwidth savings | High — saves the body transfer | None |
| What client does | Reuses cached body | Replaces cached body |
What Happens in a 304 Round-Trip
The client has previously cached a resource and stored its ETag (or Last-Modified). On a subsequent request, the client sends:
GET /image.png HTTP/1.1
If-None-Match: "abc123"
The server compares abc123 to the current ETag. If they match (the resource hasn't changed):
HTTP/1.1 304 Not Modified
ETag: "abc123"
Cache-Control: max-age=86400
No body is sent. The client uses its cached copy. This is the entire point of conditional GETs — saving the body transfer when the resource is unchanged.
Why 304 Matters for Performance
For large static assets (JS bundles, images, fonts), the body can be hundreds of KB to MB. A 304 response is typically a few hundred bytes of headers — a 100-1000× bandwidth saving for unchanged resources.
CDNs love 304s: Cloudflare, Fastly, and CloudFront all use them aggressively to revalidate stale-but-still-fresh edge cache entries against the origin without re-downloading.
When 200 Is Returned Instead
The server returns 200 (with the body) when:
- The client didn't send any conditional headers
- The ETag/Last-Modified doesn't match (resource changed)
- The server doesn't support conditional requests for this resource
- A force-refresh request (Ctrl+F5) often strips conditional headers
ETag vs Last-Modified
Two ways to enable 304s:
- ETag (
ETag: "abc123", client sendsIf-None-Match): opaque hash of the content. Most accurate. Use a strong ETag for byte-identical comparisons or weak (W/"abc") for semantic equivalence. - Last-Modified (
Last-Modified: Wed, 21 Oct 2024 07:28:00 GMT, client sendsIf-Modified-Since): timestamp-based. Less precise (1-second granularity, clock skew issues).
ETags are preferred in modern setups but Last-Modified is fine for static files where mtime is reliable.
Common Pitfalls
- Returning 304 with a body: violates RFC. Some clients ignore the body, others get confused. Always send 304 with no body.
- Forgetting to include
Cache-Controlon the 304: the cached copy uses the originalCache-Controlfrom the 200, but you can extend freshness on revalidation by including a newCache-Controlheader on the 304. - Generating different ETags on different replicas: if two backends produce different ETags for the same content (e.g., due to filesystem-level differences), users will see "always 200, never 304" and bandwidth waste.
Stack-Specific Notes
- Express: automatically computes weak ETags via the
etagsetting; sends 304 onIf-None-Matchmatches. - Nginx: sends 304 for static files automatically based on Last-Modified.
- Next.js Route Handlers: must call
headers().get('if-none-match')and returnnew Response(null, { status: 304 })manually.
Real-World Use Case
A site serving images via a CDN should let the CDN+browser combo revalidate with If-None-Match, returning 304 for unchanged images and dropping origin egress to nearly zero. For a JSON API endpoint that's expensive to compute, hash the response body to generate an ETag and return 304 when clients send a matching If-None-Match — saves both bandwidth and recomputation downstream.
Look Up Any Status Code
Related Comparisons
HTTP 301 vs 302 — Moved Permanently vs Found Comparison
http 301 vs 302: 301 is permanent and transfers SEO link equity, 302 is temporary. Learn the right choice for redirects and how Google, browsers, and CDNs treat each.
HTTP 409 vs 412 — Conflict vs Precondition Failed Comparison
http 409 vs 412: both signal state conflicts, but Conflict is for resource-level clashes while Precondition Failed is triggered by If-Match / If-None-Match headers. ETag examples included.
HTTP 303 vs 302 — See Other vs Found Status Code Comparison
http 303 vs 302: 303 is the canonical 'POST/Redirect/GET' code that always converts to GET, while 302 is ambiguous. Why 303 fixes form-resubmission in modern web apps.
HTTP 302 vs 307 — Temporary Redirect & Method Preservation Comparison
http 302 vs 307: both are temporary redirects, but 307 strictly preserves the HTTP method. When 307 fixes broken POST redirects in APIs and OAuth flows.