It seems caching of resources in browsers is broken when HTTP/2 and compression is enabled. I am not sure if this is a Spring Boot or Tomcat bug. I suspect it is being caused by the wrong Content-Length header being sent.

It seems the Content-Length header contains the length of the content before compression. IIRC, it should contain the length of the content after compression. I think this causes browsers not to cache the resources.

I have a sample project here: https://github.com/MikeN123/spring-boot-cache-sample Problem can always be reproduced in Firefox, and is also reproducible in Chrome if you are not in an incognito session and close the window between requests.

Instructions:

  1. Clone project
  2. Assign a valid SSL cert in application.properties, using a tool like mkcert. Note this is required, with an invalid cert, browsers will not cache anyway.
  3. Start project
  4. Navigate to https://localhost:8443/index.html in an incognito window in Firefox, with network inspector open
  5. Click on the link to navigate to index.html again. Notice in network inspector that jquery.js is being fetched again. It should not have been fetched again, as max-age is set to 1 year. Also note that the content-length of the jquery.js request shows the original content-length, before compression.
  6. Retry these steps, but set server.http2.enabled=false or server.compression.enabled=false. Notice the jquery.js resource does get cached properly now.

Screenshot of the network inspector showing jquery.js being reloaded: Screenshot 2020-05-01 at 14 21 40

Comment From: wilkinsona

Thanks for the sample, @MikeN123. Thanks, too, for the pointer to mkcert as I wasn't aware of it. I've reproduced the problem using Tomcat. Interestingly, I've also reproduced it using Undertow and it does not send a content-length header in the response:

HTTP/2 200 OK
content-encoding: gzip
vary: Origin
vary: Access-Control-Request-Method
vary: Access-Control-Request-Headers
last-modified: Fri, 01 May 2020 14:21:30 GMT
cache-control: max-age=31536000
content-type: application/javascript
accept-ranges: bytes
date: Fri, 01 May 2020 14:29:22 GMT
X-Firefox-Spdy: h2

Comment From: MikeN123

That's odd. Did you open a new incognito window? From my experience, once a certain URL starts to give varying responses, browser refrain from caching it (e.g., if I disable compression but not re-open my browser window, it still does not start caching the resource).

I have the an application that is exhibiting this problem also running behind an nginx proxy w/ http/2 and compression, and there the browsers do seem to cache the resources. So it does have something to do with the web server, but I still can't quite put my finger on the exact cause of the issue.

Comment From: wilkinsona

That's odd. Did you open a new incognito window?

No. Firefox fails with SSL_ERROR_NO_CYPHER_OVERLAP in a private browsing window while being able to complete the SSL handshake in a normal window. Googling suggests that's a Firefox bug.

I've switched to Chrome now where an incognito window works fine. Going back to Tomcat with HTTP/2 and compression enabled, Chrome caches jquery.js as expected. I've just tried Safari as well in a private window and it too caches jquery.js as expected. As far as I can tell, the problem appears to be specific to Firefox. Best of luck with tracking down the problem, but I don't think there's anything more we can do to help you here.

Comment From: MikeN123

I disagree, Chrome does never save the jquery.js in the disk cache (only memory cache) and closing and reopening the window causes jquery.js to be retrieved again.

Also, I am still quite sure the content-length header is invalid and that this is triggering the issue.

With Undertow and Firefox it works fine for me, further strenghtening my believe this is a Tomcat issue. Maybe @markt-asf knows a bit more about this?

Screenshot 2020-05-01 at 17 13 57

Comment From: wilkinsona

I disagree, Chrome does never save the jquery.js in the disk cache (only memory cache) and closing and reopening the window causes jquery.js to be retrieved again.

That's really a detail of how Chrome decides to cache, not whether or not something can be cached. It's also not what I see outside of an incognito window. I performed the following steps to see how Chrome behaves:

  1. Clear Chrome's cache
  2. Open https://localhost:8443/index.html
  3. Click "Navigate to this page again."
  4. Quit Chrome
  5. Launch Chrome and open https://localhost:8443/index.html

At step 2, jquery.js is requested from the server. At step 3 it comes from the in-memory cache. At step 5 it comes from the disk cache.

Comment From: MikeN123

I still don't see Chrome caching it. I'm quite sure this is a bug in Tomcat, I will file a bug with them, and it is caused by the Content-Length header that should not be send.

The issue is that the prepareResponse for Http11 first checks compression (which unsets content-length) and then sets a content-length header if necessary. Http2 inverses that order, which causes it to set a content-length header it should not set.

[edit] For reference: https://bz.apache.org/bugzilla/show_bug.cgi?id=64403

Comment From: MikeN123

In case anyone else is seeing this issue: Tomcat 9.0.35 is now available and fixes this issue. Will probably be available in Spring Boot 2.2.8.