Skip to main content
Blog|
Learning center

HTTP 304 Not Modified: What It Means and Why It Happens

|
Apr 16, 2026|12 min read
LEARNING CENTERHTTP 304 Not Modified: What ItMeans and Why It HappensHOSTNEYhostney.comApril 16, 2026

A 304 Not Modified response is the server telling your browser: “the file you have cached is still the same as the one I would send you, so I am not sending it again – just use what you already have.” It is a tiny response (no body, just headers) and it is what makes browsing the web fast on repeat visits.

If you are seeing 304s in the Network tab in developer tools, that is usually good – it means the browser is reusing cached resources instead of re-downloading them. If you are seeing 304s when you expect to see fresh content – because you just updated a file on the server and the browser is still showing the old version – that is a cache invalidation problem, and the second half of this article covers how to fix it.

How a 304 actually happens#

A 304 only makes sense in the context of conditional requests. Conditional requests are how a browser asks “is this file still the same as the version I have cached?” without forcing the server to send the whole file just to answer.

There are two conditional request headers:

  • If-Modified-Since – the browser sends the timestamp of the version it has, and asks the server to compare against the file’s current Last-Modified time. If the file has not changed since that timestamp, the server returns 304.
  • If-None-Match – the browser sends an ETag (a fingerprint of the file’s content) for the version it has cached, and asks the server to compare against the file’s current ETag. If the ETags match, the server returns 304.

The browser sends these headers automatically whenever it has a cached copy of a resource and needs to decide whether to use it or fetch a new version. The server checks the file, and either returns 200 with the new content or 304 with no body.

A 304 response includes:

  • The status line: HTTP/1.1 304 Not Modified
  • Updated cache headers (often a refreshed Cache-Control or Expires )
  • Sometimes an updated ETag or Last-Modified
  • No response body

That last point is the whole point of 304. The body is what costs bandwidth and server time. Sending “the file you have is fine, keep using it” in 200 bytes of headers instead of re-sending a 500KB JavaScript bundle is a massive efficiency win, multiplied across every visitor on every page load.

Why 304 is good (most of the time)#

In a normal browsing session, 304s mean the browser cache is doing its job. Open the Network tab on any media-heavy site, do a hard reload, then a soft reload, and watch what happens:

  • Hard reload (Ctrl+Shift+R / Cmd+Shift+R): the browser intentionally bypasses its cache. Every resource downloads fresh, status 200.
  • Soft reload (Ctrl+R / Cmd+R / F5): the browser uses its cache, but revalidates with the server. Resources that are still valid return 304. Resources that have changed return 200 with the new content.

On a typical page load with a warm cache, you might see one or two 200s for the HTML and dozens of 304s for CSS, JavaScript, fonts, and images. Each 304 saved a full download of the resource – that is the performance win.

For server administrators, a healthy ratio of 304s in your access logs means clients are caching properly and your Cache-Control headers are configured correctly. If your logs show every visitor downloading the same static assets fresh on every page load (all 200s, no 304s), something is wrong with your cache configuration.

ETag vs Last-Modified: which one you are seeing#

Both headers can trigger a 304, but they work differently. Knowing which one your server is using helps when debugging.

Last-Modified and If-Modified-Since are the older, simpler mechanism. The server sends Last-Modified: Wed, 15 Apr 2026 14:32:11 GMT along with the file. On the next request, the browser sends If-Modified-Since: Wed, 15 Apr 2026 14:32:11 GMT . The server compares – if the file has not been modified since that timestamp, it returns 304.

The limitation is that Last-Modified only has one-second precision. A file modified twice in the same second cannot be distinguished. And if a file is regenerated with the same content (a deploy that touches every file), the timestamp changes even though the content did not.

ETag and If-None-Match are the newer mechanism. The server computes a fingerprint of the file content (usually a hash, sometimes an inode + size + mtime combination) and sends it as ETag: "abc123" . On the next request, the browser sends If-None-Match: "abc123" . If the ETag still matches, the server returns 304.

ETags are more accurate because they reflect content, not timestamps. A file regenerated with identical content gets the same ETag and triggers a 304 even though the timestamp changed.

Most modern servers send both headers. Browsers prefer If-None-Match (ETag) when both are available because it is more reliable.

There is one common ETag gotcha: if your site is served from multiple servers behind a load balancer, and each server computes the ETag from the file’s inode (which differs per server), the same file gets different ETags on different servers. A request that hits server A first and server B on revalidation will not get a 304 because the ETags differ. The fix is to either disable ETags entirely on multi-server setups (rely on Last-Modified ) or compute ETags from content hashes only.

Inspecting conditional requests in DevTools#

To see what is actually happening on a request, open DevTools > Network tab, find the request you care about, and look at the Headers section.

Request headers to look for:

  • If-Modified-Since: Wed, 15 Apr 2026 14:32:11 GMT – the browser is asking “has this changed since this timestamp?”
  • If-None-Match: "abc123" – the browser is asking “does this still have this fingerprint?”

Response headers to look for:

  • Cache-Control: max-age=31536000, immutable – the resource was sent with caching instructions
  • ETag: "abc123" – the fingerprint the server is using
  • Last-Modified: Wed, 15 Apr 2026 14:32:11 GMT – the timestamp the server is using

If the response status is 304, the response will have no body but should still include refreshed cache headers. If the response is 200 even though the request had If-Modified-Since or If-None-Match , the server determined the file actually changed and is sending you the new version.

In the Network tab’s Size column, 304 responses show as a tiny number (usually under 1 KB – just headers) while 200 responses show the full transfer size. This is the easiest way to see at a glance which requests revalidated and which downloaded fresh.

When 304 becomes a problem#

The most common 304 problem is the opposite of what 304 is designed for: you updated a file on the server, but visitors are still seeing the old version because their browsers (or a CDN, or a reverse proxy) keep returning 304 against the cached old content.

There are several places this can break.

Browser cache too aggressive

If your site sends Cache-Control: max-age=31536000 for a file (cache for one year), the browser will not even ask the server for a year. No 304, no 200, just the cached version. This is correct for fingerprinted assets like app.abc123.js , but it is a problem for files that change without changing their URL (like /style.css or /index.html ).

The fix is to use shorter cache lifetimes (or no-cache / must-revalidate ) for files whose URLs do not change with their content, and long lifetimes only for files with content-hashed URLs. A typical setup:

# Versioned/fingerprinted assets - cache forever
location ~* "\.[a-f0-9]{8,}\.(js|css|png|jpg|svg|woff2)$" {
    expires 1y;
    add_header Cache-Control "public, immutable";
}

# Unversioned assets - revalidate every time
location ~* "\.(html|json|xml)$" {
    add_header Cache-Control "no-cache, must-revalidate";
}

With this configuration, fingerprinted JS/CSS bundles never re-request (no 304s, no 200s), but HTML and JSON revalidate every load (mostly 304s, occasional 200s when content actually changes).

CDN holding stale content

A CDN sits between your origin server and the browser. The browser asks the CDN for a file. If the CDN has it cached, the CDN returns it – the request never reaches your origin server. If you updated the file on the origin but the CDN still has the old version cached, the CDN keeps returning the old file (with its old ETag), and browsers keep getting 304s against that stale version.

The fix is to purge the CDN cache after deploys, or to use shorter CDN cache TTLs for files that change frequently. Most CDNs (Cloudflare, Fastly, BunnyCDN) have an API for cache purging that you can call from your deploy script.

Reverse proxy holding stale content

Same problem as a CDN, just one layer closer to your origin. If Nginx is configured with proxy_cache or fastcgi_cache in front of an application server, it caches responses and serves them directly on subsequent requests. An update to the underlying content does not automatically invalidate the proxy cache.

For Nginx FastCGI cache (common on WordPress setups), you typically need to either configure cache key rules that include a version identifier or purge the cache file directly:

# Find the cache file for a specific URL (Nginx fastcgi_cache)
grep -rl "https://www.example.com/page" /var/cache/nginx/ | xargs rm -f

If you want to script this on a deploy, WP-CLI over SSH is the cleanest way to combine a content update with a cache purge in one shell command. Better practice is to install an integration that auto-purges on content change. On WordPress, the Hostney Cache plugin does exactly this – when you publish or update a post, the relevant cache files are purged automatically (the post URL, homepage, RSS feeds, sitemaps, related archive pages) so visitors immediately see the fresh content without waiting for cache TTLs to expire.

Browser disk cache holding old content

If a visitor first loaded your site when there was a bug in your CSS, their browser may have the buggy CSS cached for whatever cache lifetime you sent. Even after you fix and deploy the new CSS, that visitor’s browser might keep using the cached buggy version until the cache lifetime expires.

There are three ways to force browsers past this:

  1. Fingerprint your filenames. app.css becomes app.abc123.css , where abc123 is a hash of the file content. When the file changes, the URL changes, and the browser has no cached version of the new URL. This is the standard approach for modern build tools.
  2. Add a query string version. app.css?v=20260416 forces the browser to treat it as a new resource. Less reliable than fingerprinting (some proxies ignore query strings for caching) but easier to retrofit.
  3. Send Cache-Control: no-cache for files you cannot fingerprint. This forces the browser to revalidate every time. You will see lots of 304s but the browser will catch updates immediately.

For a deeper look at how cache-related performance plays out beyond just 304s, see what is a cache miss and how does it affect performance.

How to force a 200 instead of a 304 (for testing)#

Sometimes you need to verify that the server is sending fresh content – that the issue is not just the browser cache lying to you. Three ways to force a real download:

Hard reload in the browser. Ctrl+Shift+R (Windows/Linux), Cmd+Shift+R (Mac). Bypasses the browser cache, sends Cache-Control: no-cache in the request, forces 200.

Disable cache in DevTools. Open DevTools, go to Network tab, check “Disable cache”. As long as DevTools is open, every request bypasses cache. Useful when you want to test fresh loads repeatedly without manually hard-reloading.

Send a curl request with no conditional headers:

curl -I https://example.com/style.css

This sends no If-Modified-Since or If-None-Match , so the server has nothing to revalidate against and must return 200 with the current content. Compare against:

curl -I -H "If-None-Match: \"abc123\"" https://example.com/style.css

If the server sends 304 with the matching ETag and 200 with a wrong ETag, conditional requests are working as designed. If you always get 200 regardless, the server is not honoring conditional requests (uncommon but happens with misconfigured caching middleware).

304 and SEO#

Search engine crawlers also send conditional requests. When Googlebot revisits a page that has not changed since the last crawl, the server returns 304, and Google saves the bandwidth of re-fetching the body. This is good for both sides – the server saves resources, and Google can crawl more pages within its crawl budget.

Sites that handle conditional requests properly tend to have more efficient crawl behavior in Google Search Console. If your access logs show Googlebot getting 200s for unchanged pages on every visit, your Last-Modified and ETag headers are not configured correctly, and you are wasting crawl budget.

The opposite problem – returning 304 for content that has actually changed – is rare but damaging. Google may take longer to notice updates to pages that wrongly return 304, slowing down the indexing of new content.

A 304 is unrelated to redirects (302, 307, and 308) even though all of them are 3xx codes. The 3xx class is “further action needed” but 304 specifically means “no further action – use your cache.” Search engines treat 304 the same as the cached 200 for ranking purposes, so a 304 response does not affect the page’s ranking signal.

How Hostney handles 304 and conditional caching#

On Hostney, the Nginx configuration that serves your sites is tuned to honor conditional requests for static assets out of the box. ETags are computed for static files, Last-Modified is set from the filesystem timestamp, and Cache-Control headers are sent with appropriate lifetimes per file type. Browsers and CDNs revalidate efficiently without you needing to configure anything.

For dynamic content (WordPress pages), the FastCGI page cache stores the rendered HTML and serves it to the next visitor. When you publish or update a post, the Hostney Cache plugin automatically purges the affected cache files – the post URL, the homepage, the RSS feed, the sitemap, and any archive pages that include the post. Visitors do not see stale content while waiting for a TTL to expire, and your origin still benefits from the cache for everyone else.

If you need to override the defaults – longer cache TTLs for an asset-heavy site, or shorter ones for content that changes minute-by-minute – the Nginx configuration is editable per-site from the control panel. The default settings are appropriate for most sites; the override is there for the cases where the defaults do not fit.

Summary#

A 304 Not Modified response is the server’s way of saying “your cached copy is still good.” It is the result of a conditional request (using If-Modified-Since or If-None-Match headers) and it saves bandwidth by avoiding the re-download of unchanged resources. Most 304s are good – they mean the cache is working as intended.

When 304s become a problem – usually after a deploy or content update where visitors keep seeing old content – the cause is almost always cache invalidation: the browser, CDN, or reverse proxy is holding a version that should have been refreshed. The fix depends on which layer is holding the stale copy: fingerprint your asset URLs to force browsers to fetch new versions, purge the CDN after deploys, and use auto-purging cache plugins on dynamic platforms like WordPress.

If you are debugging cache behavior in DevTools, the Network tab’s Status column shows you exactly which resources came from cache (304) and which downloaded fresh (200), and the response headers tell you what cache strategy each resource is using.

Related articles