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:
- Client sends
If-None-Match: "12345-gzip". - Apache calculates the ETag for the uncompressed file:
"12345". - Apache compares
"12345-gzip"(Client) vs"12345"(Server). - No match.
- Apache compresses the file and appends
-gzipto the ETag. - 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).
Travis Horn