HTTP Status Code 304: What It Means and Why It's Good
You ran a page through a header checker, or scrolled through Search Console's Crawl Stats report, and there it is: HTTP status code 304. It's sitting in a list next to 301s and 302s, and your first instinct is reasonable. A 3xx code usually means something is redirecting, or worse, broken. It isn't, in this case.
A 304 is the response a server sends when a browser or a crawler asks "has this changed since I last checked?" and the honest answer is no. No new content gets sent. No redirect happens. It is one of the quietest, most useful responses on the web, and if you keep seeing it, something is probably working exactly as it should.
I've had this exact conversation with clients more times than I can count. Someone opens their crawl stats, sees a chunk of requests logged as 304, and messages me convinced Google has flagged a problem. It hasn't. Let's walk through what this status code actually is, then get to the part most explanations skip: why some pages return it reliably and others never do.
What HTTP Status Code 304 Not Modified Actually Means
A 304 response is the answer to a conditional request. When a browser or crawler has already fetched a resource once, it can ask the server a narrower question on the next visit: "I have a version tagged X, cached on date Y. Is it still current?" It does this by sending an If-None-Match header (comparing against an ETag) or an If-Modified-Since header (comparing against a timestamp), or both.
If the server checks and finds nothing has changed, it replies with a 304 and stops there. No HTML, no image bytes, no JSON payload. Per RFC 9110's definition of the 304 status, a server generating this response must repeat only the headers (such as Cache-Control, Date, ETag, or Expires) that would have accompanied a full 200 response, and it carries no message body at all.
That is the entire mechanism. The client already has the content. The server just confirms it's still good.
This is why calling 304 a "redirect" is a category error, even though it lives in the 3xx numbering block. A real redirect (301, 302, 307, 308) sends a Location header and tells the client to go fetch something else. A 304 sends no Location header and tells the client to go nowhere: stay right where you are, what you've got is fine. If you want the fuller breakdown of how the 3xx family splits into redirects versus this kind of non-redirect confirmation, that's covered in httpcheck.tools' guide to HTTP status codes.
Is a 304 status code an error?
No. This is worth stating flatly, because the anxiety around this code is almost always the actual reason someone searches for it. Google's own Search Console documentation settles the question directly: the Crawl Stats report groups "Not modified (304)" under the heading "Good response codes," alongside 200 and permanent redirects, and describes these responses as pages that haven't changed since the last crawl request. That's Google, in its own product documentation, telling you a 304 is not a problem to fix.
It helps to remember what a 304 is actually confirming: that the conditional request mechanism worked. The server checked, found no changes, and avoided sending duplicate data. That's the caching system doing its job, not failing at it. If you're staring at a 304 in DevTools' Network tab or in an httpcheck.tools report and wondering whether to "fix" it, the short answer is that there's nothing broken here to fix.
How the conditional request handshake works (the short version)
The full mechanics of ETags, weak versus strong validators, and Last-Modified precision deserve their own deep dive, and httpcheck.tools' header-by-header guide covers that ground. For this article, the short version is enough: the first time a browser or crawler requests a resource, the server sends back a full 200 response along with a validator, usually an ETag (a fingerprint of the content) or a Last-Modified date, or both, a pattern MDN's reference on the 304 status documents clearly. The client stores that validator alongside the cached copy.
On every subsequent request, the client sends that stored validator back using If-None-Match or If-Modified-Since. The server compares it against the current state of the resource. Match, it sends 304. No match (or no validator was ever set up to compare in the first place), it sends a fresh 200 with the new content and a new validator.
That comparison step, done correctly, is the whole system.
304 vs 200: what actually changes
Side by side, the difference is smaller than people expect, and that's exactly the point. Both are success responses. Neither indicates an error. What separates them comes down to three things.
- Body: a 200 carries the full response body (the HTML, the image, the stylesheet). A 304 carries none. This is what makes it cheap: there's simply less to send.
- Headers: both responses repeat the headers relevant to caching and content identity (ETag, Cache-Control, Date, Expires), but a 304 omits anything that only makes sense alongside a body, like Content-Length reflecting actual payload size.
- What the client does next: on a 200, the browser or crawler replaces its cached copy with the new one. On a 304, it keeps using what it already has, just with a refreshed "last confirmed" timestamp.
From a user's perspective, both responses render the same page. The difference is invisible on screen and entirely about efficiency: less data transferred, less server work, faster response time. If you're comparing timings in a tool like httpcheck.tools, you'll typically see 304 responses complete faster than the equivalent 200, precisely because there's no body to generate or transmit.
How Googlebot uses 304 responses
This is where the code stops being purely a browser-caching detail and starts mattering for search visibility, and it's the part most explainers either skip or cite loosely. Google's crawling documentation is direct about it. In its "Optimize your crawl budget" guidance, Google states: "Support 304 (Not Modified) HTTP status codes. If a page hasn't changed since Google last crawled it, returning a 304 code tells Google to reuse the cached version, saving your server bandwidth and resources."
In plain terms, Googlebot works the same way a browser does. It stores what it last crawled, and on a re-crawl, it can send a conditional request asking whether anything changed. If your server answers correctly with a 304, Googlebot doesn't need to re-download and re-parse the whole page. That frees up crawl capacity that can go toward pages that did change.
Worth scoping honestly here: Google frames this guidance specifically around large or rapidly-changing sites, roughly the million-plus-page enterprise tier and the ten-thousand-plus-page tier for sites that update often. If you run a personal blog with forty posts, Googlebot isn't going to run out of patience with you either way. But the underlying behavior applies at any scale, and there's no downside to supporting it correctly regardless of site size. It's simply that the business case gets sharper the bigger and busier your site is.
Why does my page return 200 instead of 304?
Here's the question that actually gets people unstuck, and it's the one most competing articles answer too shallowly. You check your images or your CSS files and they're returning 304 on repeat requests, exactly as expected. Then you check your HTML pages and they stubbornly return 200 every single time, even when nothing on the page has changed. What gives?
There are a few possible causes, and they're worth checking roughly in this order, because the first two account for the overwhelming majority of cases I've seen in client audits.
- The server never sent a validator to begin with. If the original 200 response had no ETag and no Last-Modified header, there's nothing for a subsequent request to compare against. No validator means no conditional request is possible, full stop.
- The application never checks the conditional headers. This is the big one, and it's underexplained almost everywhere. Static files (images, CSS, JS, PDFs) get conditional GET handling automatically from most web servers like Apache and nginx. The server itself tracks the file's modification time and compares it against incoming requests without any developer having to write code for it. Dynamically generated HTML doesn't get this for free. A page rendered by PHP, a CMS, or an application framework only returns 304 if that application explicitly implements the comparison logic itself, checking the incoming If-Modified-Since or If-None-Match header against its own record of when the content last changed, and returning a 304 by hand when they match. Most frameworks and most custom applications never write that code path, because it's easy to forget and doesn't break anything visibly if it's missing. That's why an all-static site tends to show 304s everywhere, and a dynamically rendered site often shows none on its HTML at all, no matter how the caching headers are configured.
- A Cache-Control directive is preventing revalidation. A response marked
no-store(see httpcheck.tools' Cache-Control breakdown) tells the client not to cache the response at all, which means there's nothing to revalidate on the next visit. Every request starts fresh with a full 200. - A CDN or proxy in front of the origin is rewriting the response. Some edge configurations serve their own cached copy and never forward the conditional request back to the origin server, or they alter headers in a way that breaks the comparison. This one takes more digging, since the behavior you're inspecting from outside might not match what the origin server itself would do.
That second cause is the one worth sitting with. Adding an ETag header to a dynamic page's response, on its own, does not create a 304. Without server-side logic that actually reads the incoming conditional header and compares it, the ETag is decorative.
Frameworks that do this well make the pattern explicit: Ruby on Rails, for instance, ships a documented fresh_when helper built specifically to compare an incoming request against an ETag or Last-Modified value and return a 304 automatically when nothing has changed, exactly the kind of conditional-GET support that Rails' own caching guide documents in detail.
Most custom PHP or Node applications never write an equivalent code path, not because it's hard, but because it's easy to forget and nothing breaks visibly if it's missing. Getting this right takes deliberate application code, not a header tweak.
Checking this yourself
You don't need to guess at any of this. Run a page through httpcheck.tools and look at the status and caching sections of the report. A healthy pattern usually looks like this: static assets (images, fonts, CSS, JS) returning 304 on a repeat check when nothing has changed, and the HTML document itself returning either a clean 200 with proper caching headers or, if your application has implemented conditional GET, a 304 of its own. Checking with the "Googlebot" user-agent option lets you see roughly what a crawler would encounter, though remember that Googlebot's actual conditional-request behavior depends on its own crawl history for that URL, which a single manual check can't fully replicate.
If you spot the mismatch, HTML never returning 304 while everything static does, you've just confirmed the static-versus-dynamic distinction described above. That's a genuinely useful diagnostic, and it points you toward the right fix: implementing conditional-GET support in the application layer, rather than fiddling endlessly with server-level cache configuration that was never going to touch the problem.
The bottom line
A 304 is not a warning sign. It's the caching system confirming it's doing exactly what it's supposed to: recognizing unchanged content and avoiding wasted transfer. Seeing it in a crawl report, in DevTools, or in an httpcheck.tools result is a good sign, not a red flag. The more useful question, once the anxiety clears, is whether your dynamic pages support it at all, and for most sites the honest answer is that they don't, simply because nobody wrote the code to check.
For the ETag mechanics behind that comparison in more depth, httpcheck.tools' ETag guide is the place to go next. And if a redirect somewhere upstream is the reason a page never even reaches the point of returning a clean 304 or 200, indexcheck.tools has a good rundown of how redirect and indexing outcomes connect for a page that's struggling to get crawled at all.
Frequently Asked Questions
What does HTTP 304 mean?
HTTP status code 304 means the server received a conditional request (using If-Modified-Since or If-None-Match) and determined the resource hasn't changed since the client's cached copy was last confirmed. The server sends the 304 status with no response body, since the client already has the current content, and repeats only the headers relevant to caching. It's the designed, successful outcome of a conditional GET request, not a redirect and not an error.
Is a 304 status an error?
No. Google's own Search Console documentation lists "Not modified (304)" under the "Good response codes" category in the Crawl Stats report. A 304 confirms the caching mechanism is working correctly by avoiding an unnecessary data transfer.
How does Googlebot use 304 responses?
Googlebot can send conditional requests when re-crawling a URL it has previously visited, comparing the page against what it stored from the last crawl. If your server returns a 304 because nothing changed, Googlebot reuses its cached version instead of re-downloading and re-processing the full page. Google's crawl budget documentation explicitly recommends supporting 304 responses because doing so saves server resources and frees up crawl capacity, guidance that matters most for large or frequently updated sites but applies at any scale.
Why does my page return 200 instead of 304?
The most common reason is that the application generating the page never implements conditional-GET logic in the first place. Static files usually get 304 support automatically from the web server, but dynamically generated HTML only returns a 304 if the application code explicitly checks the incoming If-Modified-Since or If-None-Match header and compares it against the content's actual last-changed state. Missing validators (no ETag or Last-Modified header at all), a Cache-Control directive like no-store, or a CDN altering the response in front of the origin can also prevent a 304 from ever appearing.