Why browser does not send "If-None-Match" header?
Same Problem, Similar Solution
I've been trying to determine why Google Chrome won't send If-None-Match
headers when visiting a site that I am developing. (Chrome 46.0.2490.71 m, though I'm not sure how relevant the version is.)
This is a different — though very similar — answer than the OP ultimately cited (in a comment regarding the Accepted Answer), but it addresses the same problem:
The browser does not send the If-None-Match
header in subsequent requests "when it should" (i.e., server-side logic, via PHP or similar, has been used to send an ETag
or Last-Modified
header in the first response).
Prerequisites
Using a self-signed TLS certificate, which turns the lock red in Chrome, changes Chrome's caching behavior. Before attempting to troubleshoot an issue of this nature, install the self-signed certificate in the effective Trusted Root Store, and completely restart the browser, as explained at https://stackoverflow.com/a/19102293 .
1st Epiphany: If-None-Match requires an ETag from the server, first
I came to realize rather quickly that Chrome (and probably most or all other browsers) won't send an If-None-Match
header until the server has already sent an ETag
header in response to a previous request. Logically, this makes perfect sense; after all, how could Chrome send If-None-Match
when it's never been given the value?
This lead me to look at my server-side logic — particularly, how headers are sent when I want the user-agent to cache the response — in an effort to determine for what reason the ETag
header is not being sent in response to Chrome's very first request for the resource. I had made a calculated effort to include the ETag
header in my application logic.
I happen to be using PHP, so @Mehran's (the OP's) comment jumped-out at me (he/she says that calling header_remove()
before sending the desired cache-related headers solves the problem).
Candidly, I was skeptical about this solution, because a) I was pretty sure that PHP wouldn't send any headers of its own by default (and it doesn't, given my configuration); and b) when I called var_dump(headers_list());
just before setting my custom caching headers in PHP, the only header set was one that I was setting intentionally just above:
header('Content-type: application/javascript; charset=utf-8');
So, having nothing to lose, I tried calling header_remove();
just before sending my custom headers. And much to my surprise, PHP began sending the ETag
header all of a sudden!
2nd Epiphany: gzipping the response changes its hash
It then me hit me like a bag of bricks: by specifying the Content-type
header in PHP, I was telling NGINX (the webserver I'm using) to GZIP the response once PHP hands it back to NGINX! To be clear, the Content-type
that I was specifying was on NGINX's list of types to gzip.
For thoroughness, my NGINX GZIP settings are as follows, and PHP is wired-up to NGINX via php-fpm:
gzip on;
gzip_min_length 1;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript application/javascript image/svg+xml;
gzip_vary on;
I pondered why NGINX might remove the ETag
that I had sent in PHP when a "gzippable" content-type is specified, and came up with a now-obvious answer: because NGINX modifies the response body that PHP passes back when NGINX gzips it! This makes perfect sense; there is no point in sending the ETag
when it's not going to match the response used to generate it. It's pretty slick that NGINX handles this scenario so intelligently.
I don't know if NGINX has always been smart enough not to compress response bodies that are uncompressed but contain ETag
headers, but that seems to be what's happening here.
UPDATE: I found commentary that explains NGINX's behavior in this regard, which in turn cites two valuable discussions regarding this subject:
- NGINX forum thread discussing the behavior.
- Tangentially-related discussion in a project repository; see the comment
Posted on Jun 15, 2013 by Massive Bird
.
In the interest of preserving this valuable explanation, should it happen to disappear, I quote from Massive Bird
's contribution to the discussion:
Nginx strips the Etag when gzipping a response on the fly. This is according to spec, as the non-gzipped response is not byte-for-byte comparable to the gzipped response.
However, NGINX's behavior in this regard might be considered slightly flawed in that the same spec
... also says there is a thing called weak Etags (an Etag value prefixed with W/), and tells us it can be used to check if a response is semantically equivalent. In that case, Nginx should not mess with it. Unfortunately, that check never made it into the source tree [citation is now filled with spam, sadly]."
I'm unsure as to NGINX's current disposition in this regard, and specifically, whether or not it has added support for "weak" Etags.
So, What's the Solution?
So, what's the solution to getting ETag
back into the response? Do the gzipping in PHP, so that NGINX sees that the response is already compressed, and simply passes it along while leaving the ETag
header intact:
ob_start('ob_gzhandler');
Once I added this call prior to sending the headers and the response body, PHP began sending the ETag
value with every response. Yes!
Other Lessons Learned
Here are some interesting tidbits gleaned from my research. This information is rather handy when attempting to test a server-side caching implementation, whether in PHP or another language.
Chrome, and its Developer Tools "Net" panel, behave differently depending on how the request is initiated.
If the request is "made fresh", e.g., by pressing Ctrl+F5
, Chrome sends these headers:
Cache-Control: no-cache
Pragma: no-cache
and the server responds 200 OK
.
If the request is made with only F5
, Chrome sends these headers:
Pragma: no-cache
and the server responds 304 Not Modified
.
Lastly, if the request is made by clicking on a link to the page you're already viewing, or placing focus into Chrome's address bar and pressing Enter, Chrome sends these headers:
Cache-Control: no-cache
Pragma: no-cache
and the server responds 200 OK (from cache)
.
While this behavior a bit confusing at first, if you don't know how it works, it's ideal behavior, because it allows one to test every possible request/response scenario very thoroughly.
Perhaps most confusing is that Chrome automatically inserts the Cache-Control: no-cache
and Pragma: no-cache
headers in the outgoing request when in fact Chrome is acquiring the responses from its cache (as evidenced in the 200 OK (from cache)
response).
This experience was rather informative for me, and I hope others find this analysis of value in the future.
Posting this for future me...
I was having a similar problem, I was sending ETag
in the response, but the HTTP client wasn't sending a If-None-Match
header in subsequent requests (which was strange because it was the day before).
Turns out I was using http://localhost:9000
for development (which didn't use If-None-Match
) - by switching to http://127.0.0.1:9000
Chrome1 automatically started sending the If-None-Match
header in requests again.
Additionally - ensure Devtools > Network > Disable Cache [ ]
is unchecked.
Chrome: Version 71.0.3578.98 (Official Build) (64-bit)
1 I can't find anywhere this is documented - I'm assuming Chrome was responsible for this logic.
Your response headers include Cache-Control: no-store, no-cache
; these prevent caching.
Remove those values (I think must-revalidate, post-check=0, pre-check=0
could/should be kept – they tell the browser to check with the server if there was a change).
And I would stick with Last-Modified
alone (if the changes to your resources can be detected using this criterion alone) – ETag
is a more complicated thing to handle (especially if you want to deal with it in your PHP script yourself), and Google PageSpeed/YSlow advise against this one too.