Migrate to RunxBuild and earn up to $50 in hosting credit on your first deposit.

Calculate your savings
unxBuild
Back to Blog Explainer

HTTP 304 Is Not an Error, and Seeing It Means Caching Works

Sean

Platform Writer

Sep 02, 2026
8 min read

A 304 in your network tab is not an error. It is the server saying the copy you already have is still current, so here are the headers and nothing else — which is the single most efficient response HTTP can produce short of not making the request at all.

HTTP 304 Is Not an Error, and Seeing It Means Caching Works

It appears in the developer tools alongside genuine failures, coloured the same way, and shows a suspicious zero-byte body, which is why it gets reported as a problem. The situations where a 304 actually indicates something wrong are real but specific, and they are worth being able to distinguish from the normal case where it means caching is doing its job.

Table of contents

How a conditional request works

The mechanism is a two-step conversation spread across two visits.

  1. On the first request the server sends the file with validators: an ETag — an opaque identifier for this version of the content — and usually a Last-Modified timestamp.
  2. The browser stores the file along with those validators.
  3. On a later request, if the cached copy is stale by the freshness rules, the browser asks conditionally: If-None-Match carrying the ETag, or If-Modified-Since carrying the timestamp.
  4. The server compares. If the resource has not changed it returns 304 Not Modified with headers and no body. If it has, it returns 200 with the new content and new validators.

The saving is the body, which on a large JavaScript bundle or an image is essentially the whole transfer. The request still happens — a round trip is still spent — but nothing meaningful is downloaded.

This is why 304 responses must not carry a body, and why a zero-byte response in the network tab is correct rather than suspicious. The spec requires it, and browsers behave oddly when a server erroneously sends one on a persistent connection.

The 304 also carries updated caching headers, which lets the server extend the freshness window without resending anything — so a resource can stay cached indefinitely while remaining verifiably current.

Freshness versus validation, which people conflate

There are two distinct mechanisms and mixing them up is the source of most caching confusion.

Freshness is Cache-Control: max-age. While a response is fresh, the browser uses the cached copy with no request at all. Zero network activity, zero latency. This is strictly better than a 304 because there is no round trip.

Validation is the ETag conversation above. It happens after freshness expires, and it costs a round trip to save a body.

The consequence is a strategy, not a preference:

  • For fingerprinted assets — files whose name contains a content hash, like app.4f2a91.js — set a very long max-age and immutable. The name changes when the content changes, so the file at that URL never needs revalidating. These should produce no requests at all, not 304s.
  • For HTML and anything at a stable URL whose content changes, use a short or zero max-age with an ETag. These correctly produce 304s.
  • For anything genuinely uncacheable, say so explicitly with no-store rather than relying on the absence of headers.

Seeing 304s on hashed asset filenames means your caching headers are leaving performance on the table: you are spending a round trip per asset to confirm something that could not possibly have changed.

Strong versus weak ETags, and the proxy problem

An ETag prefixed with W/ is weak: it asserts semantic equivalence rather than byte-identity. A strong ETag asserts the bytes are identical.

This matters for range requests. Resuming a partial download or seeking in a video requires a strong validator, because the client needs certainty that the bytes it already has line up with the bytes it is asking for. Weak ETags cannot support that, so media and large downloads need strong ones.

The operational trap is compression. Many servers generate ETags from file metadata and then compress the response, producing different bytes for the same ETag depending on whether the client accepted gzip or brotli. Correct behaviour is either a weak ETag or a distinct ETag per encoding, alongside a Vary: Accept-Encoding header so caches keep the variants separate.

The related trap is multiple servers. If ETags derive from inode numbers or file timestamps, two servers behind a load balancer generate different ETags for identical files, so validation fails at random depending on which one answered. Derive ETags from content — a hash — so every server agrees.

When a 304 is actually a problem

The legitimate complaints, and what each one actually is.

  • Updated content not appearing. The server is returning 304 for something that did change, meaning the validator is not tracking content. Usually an ETag from file metadata that did not update, or an over-long max-age on a URL whose content changes. Fix the validator or fingerprint the filename.
  • A tool reporting 304 as a failure. HTTP clients and scrapers frequently treat any non-200 as an error. That is the client’s bug. Handle 304 as success-with-cached-content, or send no conditional headers if you always want the body.
  • 304s on fingerprinted assets. Not a failure, just wasted round trips. Set a long immutable max-age.
  • Inconsistent 304s across servers. ETags derived from filesystem metadata differing per machine. Derive from content instead.
  • API clients caching unexpectedly. An API returning ETags is correct and useful, but a client that caches a resource it needed fresh will see stale data. Be explicit with no-store where freshness is required.

The fastest way to tell whether a 304 is correct: hard-reload the page, which sends Cache-Control: no-cache and forces a full response. If the fresh content is right and the cached content was wrong, the validator is broken. If both are the same, the 304 was accurate and something else is going on.

Getting the headers right once

A configuration that works for essentially every site, set at the edge or in the host’s header rules rather than per-framework.

  • Fingerprinted static assetsCache-Control: public, max-age=31536000, immutable. Cached for a year, never revalidated, and safe because a content change produces a new filename.
  • HTMLCache-Control: public, max-age=0, must-revalidate with an ETag. Always checked, rarely re-downloaded, so a deploy is visible immediately without resending unchanged pages.
  • Images and media at stable URLs — a moderate max-age with a strong ETag so range requests work.
  • Authenticated or personalised responsesCache-Control: private, no-store, so shared caches never hold them.
  • Vary: Accept-Encoding wherever compression is negotiated.

Fingerprinting your build output is the single change that makes the rest of this straightforward. Once filenames encode content, aggressive caching is safe by construction and the whole category of stale-asset bug disappears.

And to restate the headline: a network tab full of 304s on HTML is a correctly configured site. That is what working looks like.

How this fits the rest of the stack

Cache headers are the cheapest performance and bandwidth lever available — a correctly cached asset is one you serve once instead of thousands of times, which shows up directly in transfer costs. The RunxBuild hosting calculator shows bandwidth beside the service and storage so the effect is visible. On RunxBuild static sites, response headers are configuration in the repository rather than server files, so the caching policy above ships with the build and survives the next deploy.

Useful related references:

FAQ

Is HTTP 304 an error?

No. It means the resource has not changed and your cached copy is still valid, so the server sent headers without a body. It appears alongside errors in developer tools and shows a zero-byte response, which is correct behaviour — 304 responses are required not to carry a body.

Why is my updated file still showing the old version with a 304?

The validator is not tracking the content. Usually the ETag is derived from file metadata that did not change, or max-age is too long on a URL whose content changes. Hard-reload to confirm, then either fix the ETag to derive from content or fingerprint the filename so a change produces a new URL.

What is the difference between a 304 and a cache hit with no request?

A cache hit within the freshness window makes no network request at all — zero latency. A 304 costs a round trip to confirm nothing changed, saving only the body. Freshness is strictly better, which is why fingerprinted assets should have a long immutable max-age rather than producing 304s.

What is a weak ETag?

One prefixed with W/, asserting the content is semantically equivalent rather than byte-identical. Weak ETags cannot support range requests, so resumable downloads and video seeking need strong ones. Compression commonly forces weak ETags unless the server issues a distinct ETag per encoding.

Why do I get inconsistent 304s behind a load balancer?

Because ETags are being derived from filesystem metadata such as inode numbers or timestamps, which differ per server for identical files. Validation then succeeds or fails depending on which machine answered. Derive ETags from content hashes so every server produces the same value.

#304 error#HTTP caching#ETag#conditional requests#performance