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 often prioritize the ETag because it is considered 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.
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 simply disable ETags when compression is active, forcing the
browser and server to rely on the If-Modified-Since header instead.
Inside your <IfModule mod_deflate.c> block, add FileETag None:
<IfModule mod_deflate.c>
# ... your compression settings ...
AddOutputFilterByType DEFLATE text/plain text/html ...
# Disable ETags so the server falls back to Last-Modified
FileETag None
</IfModule>
With ETags removed, the client and server successfully fall back to
If-Modified-Since and Last-Modified. Since those timestamps match, Apache
finally respects the cache and returns the elusive 304 Not Modified.
Removing ETags is Usually Fine
Disabling a standard HTTP feature like ETags might feel like a drastic measure, but for most static sites, it is actually a safe and often recommended optimization.
The only real downside is that Last-Modified has a one-second resolution. It
works by checking the file’s timestamp. Unless you are modifying the exact same
file multiple times within a single second, the timestamp precision is more than
enough for browsers to detect changes.
Travis Horn