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:

  1. NGINX forum thread discussing the behavior.
  2. 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.

chrome dev tools description


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.