Understanding HTTP 304 Not Modified: Conditional Requests, ETags, and Why It's a Success
304 Not Modified is the status code that tells a client "you already have this — stop downloading it." It's easy to mistake for an error the first time you see it in a network tab with an empty response body and no obvious content, but 304 is a success response, not a failure: it's the server confirming that the copy the client already holds is still the current one. This post covers what 304 actually promises, how the conditional-request machinery (ETag, If-None-Match, Last-Modified, If-Modified-Since) that produces it works, how it compares to a full 200 response and a failed 412 precondition, and how to return it correctly.
What Is a 304?
A 304 tells the client that a conditional request it made would have returned a 200 OK — except the condition it attached evaluated to false, meaning nothing has changed, so the server is sending nothing.
The 304 (Not Modified) status code indicates that a conditional GET or HEAD request has been received and would have resulted in a 200 (OK) response if it were not for the fact that the condition evaluated to false. — RFC 9110, Section 15.4.5
In plain English: the resource hasn't changed since the client last fetched it, so the client should keep using its cached copy. This is not an error — it's the server saving both sides bandwidth by confirming "yep, still current" instead of resending a resource the client already has. Like 204, a 304 response must not contain a message body.
304 is not an error — it's a successful response indicating the cache is still valid. Always return appropriate cache headers (
ETagorLast-Modified) with the 304 so the client can validate the cache again in the future.
How Conditional Requests Actually Produce a 304
304 never happens on its own — it's the second half of a two-request exchange:
- First request: the client fetches a resource normally. The server responds
200 OKwith the content, plus a validator: anETag(an opaque fingerprint of the content) and/or aLast-Modifiedtimestamp. - Client caches the response body along with that validator.
- Next request: the client asks for the same resource again, but attaches the validator it saved —
If-None-Match: "abc123"(echoing theETag) orIf-Modified-Since: <timestamp>(echoingLast-Modified). - Server compares the client's validator against the resource's current state. If they still match, the server returns 304 with no body — just headers, including a fresh
ETagso the client can validate again next time. If they don't match, the server returns a normal200with the new content.
GET /styles.css HTTP/1.1
If-None-Match: "abc123"HTTP/1.1 304 Not Modified
ETag: "abc123"The mental model: 304 is the server saying "your condition was false — nothing to report" rather than "your request failed." A 200 and a 304 are two possible endings to the same conditional request; which one you get depends entirely on whether the resource changed.
304 vs 200 vs 412
Three codes revolve around conditional requests, and they answer different questions:
| Code | Meaning | Use when |
|---|---|---|
| 200 OK | Condition true / no condition | Resource changed since the client's validator, or no conditional headers were sent — full body returned |
| 304 Not Modified | Read-condition false | If-None-Match / If-Modified-Since on a GET/HEAD — the resource is unchanged, so nothing is sent |
| 412 Precondition Failed | Write-condition false | If-Match / If-Unmodified-Since on a PUT/DELETE/POST — the resource did change, so the write is refused |
304 and 412 are mirror images that are easy to conflate because both mean "your condition didn't hold" — but they apply to opposite intents. 304 is for safe reads (If-None-Match, "only send me the body if it's different") where a false condition just means "nothing to send." 412 is for unsafe writes (If-Match, "only perform this write if the resource is still what I last saw") where a false condition means the write must be rejected to avoid clobbering someone else's change — the classic optimistic-concurrency guard against the lost-update problem.
Common Causes
304 responses come from a handful of well-understood situations:
- Browser cache is still valid — the browser's own HTTP cache attaches a conditional header automatically when revalidating a cached resource whose freshness lifetime has expired.
- The resource hasn't changed since
If-Modified-Since— a timestamp-based check found no changes since the client's last fetch. ETagmatches, no download needed — a content-hash-based check found the fingerprint is identical, which is more reliable than timestamps for resources that can change without their modification time updating (or vice versa).
Returning 304 Correctly
Express / Node.js
Express computes freshness for you via req.fresh, which compares the request's conditional headers against the ETag/Last-Modified you've set on the response:
app.get("/api/articles/:id", (req, res) => {
const article = getArticle(req.params.id);
res.set("ETag", article.etag);
res.set("Last-Modified", article.updatedAt.toUTCString());
if (req.fresh) {
// Client's cached copy matches — send nothing
return res.status(304).end();
}
res.json(article);
});res.send()/res.json() also do this automatically in many cases: Express generates an ETag for the outgoing body, checks it against the incoming conditional headers, and if the request turns out to be fresh, flips the status to 304 and strips Content-Type, Content-Length, and the body for you before sending.
Next.js App Router
Route Handlers work with the standard Request/Headers API, so the same pattern applies manually — read the incoming If-None-Match, compare it to the resource's current validator, and short-circuit:
// app/api/articles/[id]/route.ts
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const article = await getArticle(id);
const etag = `"${article.hash}"`;
if (request.headers.get("if-none-match") === etag) {
return new Response(null, { status: 304, headers: { ETag: etag } });
}
return Response.json(article, { headers: { ETag: etag } });
}NGINX
For static files, NGINX handles conditional requests automatically — it sends ETag and Last-Modified for served files by default, and answers a matching If-None-Match or If-Modified-Since with 304 without any extra configuration:
location /assets/ {
root /var/www/static;
# ETag and conditional 304 handling are on by default for static files
}For dynamic responses proxied from an upstream, make sure the upstream itself sets ETag/Last-Modified and honors the conditional headers — NGINX won't manufacture validators for content it didn't generate.
304 and SEO
304's SEO story is quiet, because it's purely a caching optimization rather than a content signal:
- Indexing — 304 responses don't affect indexing directly. The previously indexed content simply stays indexed, unchanged.
- Crawl behavior — crawlers use 304 to reduce bandwidth and crawl more efficiently; it tells them the cached version they hold is still valid, so they don't need to re-download or re-render it.
- Canonical signals — none. The URL's indexing status was determined by the original
200response, not by any subsequent 304. - Crawl budget — proper use of 304 with
ETag/Last-Modifiedimproves crawl efficiency, which in practice lets crawlers spend their limited per-site budget on pages that actually changed instead of re-fetching unchanged ones.
Common Pitfalls
- Treating a 304 in the network tab as a bug. An "empty" response with status 304 and no visible body is working as intended — it means the cache hit, not that something broke.
- Serving a 304 without a validator. If you don't send
ETagorLast-Modifiedon the original200, the client has nothing to send back on the next request, and conditional caching never engages — every request falls through to a full200. - Generating a new
ETagon every request for genuinely unchanged content. Some serialization pipelines re-stringify a JSON body with a different key order or a fresh timestamp field on every call, changing the hash even though the meaningful content is identical — silently disabling 304 for that endpoint. - Sending a body alongside 304. Like 204, a 304 response must have no message body — attach one and you're violating the spec, even if most clients will ignore it.
- Confusing 304 (read, unchanged) with 412 (write, precondition failed). They both mean "condition false," but 304 responds to
If-None-Matchon a safeGET, while 412 responds toIf-Matchon a write. Mixing them up on aPUThandler can mean silently discarding a legitimate write instead of rejecting a conflicting one.
Wrapping Up
304 is one of the few status codes that represents the absence of a response body as the entire point. The rules of thumb:
- 304 is a success response, not an error — "your cached copy is still correct"
- It only ever answers a conditional
GET/HEADthat carriedIf-None-MatchorIf-Modified-Since - Always pair it with a stable
ETag(preferred) orLast-Modifiedon the original response, or conditional caching never has anything to compare against - Never attach a body to a 304 — and don't confuse it with 412, which is what a failed condition looks like on a write
For more, see our pages on 304 Not Modified, 200 OK, and 412 Precondition Failed. The empty-body sibling of this code is covered in our understanding 204 No Content post.