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.
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
- Freshness versus validation, which people conflate
- Strong versus weak ETags, and the proxy problem
- When a 304 is actually a problem
- Getting the headers right once
- How this fits the rest of the stack
- FAQ
How a conditional request works
The mechanism is a two-step conversation spread across two visits.
- On the first request the server sends the file with validators: an
ETag— an opaque identifier for this version of the content — and usually aLast-Modifiedtimestamp. - The browser stores the file along with those validators.
- On a later request, if the cached copy is stale by the freshness rules, the browser asks conditionally:
If-None-Matchcarrying the ETag, orIf-Modified-Sincecarrying the timestamp. - The server compares. If the resource has not changed it returns
304 Not Modifiedwith headers and no body. If it has, it returns200with 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 longmax-ageandimmutable. 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-agewith an ETag. These correctly produce 304s. - For anything genuinely uncacheable, say so explicitly with
no-storerather 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-ageon 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-storewhere 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 assets —
Cache-Control: public, max-age=31536000, immutable. Cached for a year, never revalidated, and safe because a content change produces a new filename. - HTML —
Cache-Control: public, max-age=0, must-revalidatewith 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-agewith a strong ETag so range requests work. - Authenticated or personalised responses —
Cache-Control: private, no-store, so shared caches never hold them. Vary: Accept-Encodingwherever 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:
- What Is a 401 Error: Unauthenticated, Not Unauthorised
- SSL_connect Error 5: What SSL_ERROR_SYSCALL Actually Means
- mkdir -p: Creating Parent Directories Without the Error
- Services on RunxBuild
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.