Why Apache Returns 200 OK Instead of 304 When Last-Modified Matches If-Modified-Since: Caching Behavior Explained


2 views

When implementing HTTP caching strategies, many developers encounter a puzzling scenario: despite matching Last-Modified and If-Modified-Since headers, Apache sometimes returns 200 OK instead of the expected 304 Not Modified. Let's dissect why this happens and how to achieve predictable caching behavior.

In HTTP caching, several headers work together:

Request Headers:
If-Modified-Since: [date]
If-None-Match: [ETag]

Response Headers:
Last-Modified: [date]
ETag: [entity tag]
Cache-Control: [directives]

The complete validation process considers both modification dates and ETags. When browsers send both If-Modified-Since and If-None-Match, the server must validate against both.

Your setup reveals several important points:

Cache-Control: No-cache
If-Modified-Since: Tue, 14 Oct 2014 15:10:37 GMT
Last-Modified: Tue, 14 Oct 2014 15:10:37 GMT
ETag: "1461-505636af08fcd-gzip"

The presence of Cache-Control: No-cache actually means "revalidate with server before using cached version," not "don't cache." However, the real culprit is often ETag validation.

Apache's default behavior considers both modification time and ETag. Even if dates match, ETag validation might fail because:

  • File size changes (even with same content)
  • Inode numbers differ in cluster environments
  • Gzip compression alters the entity

Your observation about ETags confirms this:

# Apache configuration to disable ETags
Header unset ETag
FileETag None

For consistent 304 responses, implement these configurations:

Option 1: Disable ETags Completely

<IfModule mod_headers.c>
    Header unset ETag
</IfModule>
FileETag None

Option 2: Simplified ETag Generation

FileETag MTime Size

Option 3: Fine-tuned Cache-Control

<FilesMatch "\.(html|css|js)$">
    Header set Cache-Control "max-age=0, must-revalidate"
</FilesMatch>

Test your configuration with:

curl -I http://yoursite.com/resource
curl -H "If-Modified-Since: [date]" -H "If-None-Match: [etag]" -I http://yoursite.com/resource

In load-balanced environments, ensure consistent file metadata across servers:

# In apache.conf
FileETag INode MTime Size

Remember that different servers might have different inodes for the same file, causing ETag mismatches.

Add this to your VirtualHost to track 304 responses:

CustomLog logs/access.log "%h %l %u %t \"%r\" %>s %b\"%{Referer}i\" \"%{User-Agent}i\"" env=!dontlog
SetEnvIf Request_Protocol "HTTP/1.1" is_http_1_1
SetEnvIf status 304 dontlog

This helps monitor caching behavior without cluttering logs with successful 304s.


When working with HTTP caching, we expect servers to return 304 Not Modified responses when both conditions are met:

  1. The If-Modified-Since header matches the resource's Last-Modified date
  2. The If-None-Match header matches the current ETag

However, as shown in the example request/response headers:

Request Headers:
If-Modified-Since: Tue, 14 Oct 2014 15:10:37 GMT
If-None-Match: "1461-505636af08fcd-gzip"

Response Headers:
Last-Modified: Tue, 14 Oct 2014 15:10:37 GMT  
ETag: "1461-505636af08fcd-gzip"
Status Code: 200 OK

Apache's behavior stems from these technical realities:

  • Cache-Control: no-cache takes precedence over validation headers
  • ETag comparison happens before Last-Modified validation
  • Gzip encoding creates complications with ETag matching

Here are three approaches to achieve consistent 304 responses:

Option 1: Disable ETags (Recommended)

# In Apache configuration
Header unset ETag
FileETag None

Option 2: Configure Cache-Control Properly

<FilesMatch "\.(html|css|js)$">
    Header set Cache-Control "max-age=0, must-revalidate"
</FilesMatch>

Option 3: Force Weak ETag Validation

# In .htaccess
FileETag MTime Size
Header edit ETag ^"(.+)"$ "W/\"$1\""

Verify the solution works using cURL:

curl -I -H "If-Modified-Since: [last_modified_date]" \
-H "If-None-Match: [etag_value]" [your_url]

The proper response should now show:

HTTP/1.1 304 Not Modified