Profile photo of Travis Horn Travis Horn

Why Apache is Returning 200 OK Instead of 304 Not Modified

2025-12-16
Why Apache is Returning 200 OK Instead of 304 Not Modified

I recently ran into a frustrating caching issue with Apache. I requested a page where the request headers included If-Modified-Since and the response headers included Last-Modified. Since the timestamps matched, the server should have responded with a 304 Not Modified (and no content). Instead, it kept responding with a 200 OK and the full content payload.

Here is how I went down the rabbit hole to fix it.

How Conditional Requests Work

Before diving into the specific error, it helps to understand the “dance” the browser and server perform to decide whether to download a file again or use the cached version. This mechanism is called a Conditional GET.

Ideally, if the browser already has the file, we want the server to respond with a 304 Not Modified (header only, zero bytes of content) rather than a 200 OK (which re-sends the entire file).

There are two primary ways validators handle this.

Using time-based validation, the server sends a Last-Modified (a timestamp of when the file changed). The client asks for a If-Modified-Since (sending that timestamp back). If the file hasn’t changed since that date, return 304.

Using content-based validation (ETags), the server sends an ETag (a unique hash or fingerprint of the file contents, e.g., "12345"). The client asks for a If-None-Match (sending that hash back). If the file’s current hash matches the one the client has, return 304.

Usually, browsers send both. However, HTTP specifications and server configurations usually prioritize the ETag because it is a more accurate validator than a simple timestamp.

The Investigation

First, I discovered that Apache prioritizes If-None-Match over If-Modified-Since. My request headers did indeed include If-None-Match, and the response headers included an ETag. Even though the values seemed to match, I was still getting a 200 OK instead of the expected 304 Not Modified.

Then I remembered I had enabled compression (mod_deflate) using AddOutputFilterByType.

It turns out that when Gzip compression is enabled, Apache appends a -gzip suffix to the ETag. This suffix was present in both my If-None-Match sent by the client and the ETag returned by the server. So, why was the cache validation still failing?

The Root Cause

The issue lies in Apache’s order of operations. Apache calculates the ETag for the underlying file and compares it to the client’s header before it applies compression.

The flow looks like this:

  1. Client sends If-None-Match: "12345-gzip".
  2. Apache calculates the ETag for the uncompressed file: "12345".
  3. Apache compares "12345-gzip" (Client) vs "12345" (Server).
  4. No match.
  5. Apache compresses the file and appends -gzip to the ETag.
  6. Apache sends 200 OK with ETag: "12345-gzip".

Because the suffix is added after the comparison, the client’s ETag will never match the server’s calculated ETag.

The Solution

The solution is to tell mod_deflate not to alter the ETag.

Inside your <IfModule mod_deflate.c> block, add DeflateAlterETag NoChange:

<IfModule mod_deflate.c>
    # ... your compression settings ...
    AddOutputFilterByType DEFLATE text/plain text/html ...

    # Do not alter the ETag
    DeflateAlterETag NoChange
</IfModule>

Since the ETag remains the same regardless of compression, Apache finally respects the cache and returns the elusive 304 Not Modified.

Caveat

The DeflateAlterETag directive is available in Apache 2.4.58 and later. If you’re running an older version, you will need to upgrade Apache or seek another solution (Hint: If you disable ETags completely, Apache and browsers fall back to time-based validation).

Here are some more articles you might like: