Amazon S3/CloudFront 304s stripping Cache-Control headers
TL;DR: Beware of relying on
Cache-Control: max-age and
Expires HTTP header fallback behavior on Amazon CloudFront. The
Cache-Control header may get stripped on CloudFront 304s, and browsers will then have to fall back to whatever is in the
Expires header. If that
Expires date has passed, or if you never specified it, all subsequent requests for the resource will be conditionally validated by the browser.
Update 2011-12-18: The Amazon CloudFront team has fixed the issue!
I was looking at my web server’s health metrics recently (via Cacti), and noticed a spike in outbound traffic and HTTP requests. Analytics logs didn’t show a large increase in visitors or page loads, and it looked like the additional traffic was simply an increase in requests for static images.
The static images in question have HTTP Cache headers set for 1 year into the future, so they can be easily cached by browsers and proxies per performance best-practices. The suggested way to set a far expiry date is by setting both the
Cache-Control header (eg,
Cache-Control: public, max-age=31536000), as well as an
Expires header with a static date set for the same time in the future. The
Expires header is a HTTP/1.0 header that sets a specific date, say Jan 1 2011, whereas
Cache-Control is relative, in seconds. Theoretically, if both
Expires headers are sent, the
Cache-Control header should take precedence, so it’s safe to additionally set
Expires for fall-back cases.
This combination of caching header behavior works good if you are using Amazon’s CloudFront CDN, backed by static files on Amazon S3, which is what I use for several sites. The files are uploaded once to S3, and their HTTP headers are set at upload time. For the static images, I am uploading them with a 1-year max-age expiry and an
Expires header 1 year from when they’re uploaded. For example, I uploaded an image to S3 on Oct 5 2010 with these headers:
Cache-Control: public, max-age=31536000 Expires: Thu, 05 Oct 2011 22:45:05 GMT
Theoretically, HTTP/1.1 clients (current web browsers) and even ancient HTTP/1.0 proxies should both be able to understand these headers. Even though the
Expires header was for Oct 5 2011 (a couple days ago),
Cache-Control should take precedence and the content should still be fresh for all current web browsers that recently downloaded the file. HTTP/1.0 proxies will only understand the
Expires header, and they may want to conditionally validate the content if the date is past Oct 5 2011, but they should be a small part of HTTP accesses.
So my first thought was that the additional load on the server was from HTTP/1.0 proxies re-validating the already-expired content since I had set the content to expire in 1 year and that date had just passed. I should have set a much-further expiry in the first place — these images never change. To fix this, I could easily just re-upload the content with a much longer
Expires (30 years from now should be sufficient).
However, as I was investigating the issue, I noticed via the F12 Developer Tools that IE9 was conditionally validating some of the already-expired images, even though the
Cache-Control header should be taking precedence. Multiple images were being conditionally re-validated (incurring a HTTP request and 304 response), for every IE session. All of these images had
Expires header date that recently passed.
After I cleared my IE browser cache, the problem no longer repro’d. It was only after I happened to F5 the page (refresh) that the past-
Expires images were being conditionally requested again on subsequent navigations.
Take, for example, this request of a static file on my webserver that expired back on Jan 1, 2010:
GET /test/test-public.txt HTTP/1.1 Accept: text/html, application/xhtml+xml, */* Accept-Language: en-US User-Agent: Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0) Accept-Encoding: gzip, deflate Connection: Keep-Alive Host: cf.nicj.net
HTTP/1.0 200 OK Date: Sat, 08 Oct 2011 02:28:03 GMT Cache-Control: public, max-age=946707779 Expires: Fri, 01 Jan 2010 00:00:00 GMT Last-Modified: Sat, 08 Oct 2011 02:25:58 GMT ETag: "098f6bcd4621d373cade4e832627b4f6" Accept-Ranges: bytes Content-Type: text/plain Content-Length: 4 Server: AmazonS3
IE and other modern browsers will download this content today, and treat it as fresh for 30 years (946,707,779 seconds), due to the
Cache-Control header taking precedence over the Jan 1, 2010
The problem comes when, for whatever reason, a browser conditionally re-validates the content (via
If-Modified-Since). Here are IE’s request headers and Amazon’s CloudFront response headers:
GET /test/test-public.txt HTTP/1.1 Accept: text/html, application/xhtml+xml, */* Accept-Language: en-US User-Agent: Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0) Accept-Encoding: gzip, deflate Connection: Keep-Alive Host: cf.nicj.net If-Modified-Since: Sat, 08 Oct 2011 02:28:03 GMT
HTTP/1.0 304 Not Modified Date: Sat, 08 Oct 2011 02:31:54 GMT Content-Type: text/plain Expires: Fri, 01 Jan 2010 00:00:00 GMT Last-Modified: Sat, 08 Oct 2011 02:25:58 GMT ETag: "098f6bcd4621d373cade4e832627b4f6" Age: 232
We see the additional
If-Modifed-Since in the request, and the same
Expires date in the response. Unfortunately, there’s an important missing header in this response: the
Cache-Control header. It appears, at least from my testing, that CloudFront strips the
Cache-Control headers from 304 responses.
After this happens it appears that IE forgets the original
Cache-Control header so all subsequent navigations to the page will trigger conditional GETs for those resources. Since the 304 is missing the
Cache-Control header, it just sees the
Expires tag, and thinks it needs to always re-validate the content from now on.
Why This Would Happen
But what’s causing the re-validation (
If-Modifed-Since) and subsequent 304 in the first place?
User agents shouldn’t normally re-validate these resources, since the original
Cache-Control header should keep it fresh for quite a while. Except, when you either force a refresh (F5) of the page, or if the content has passed its natural freshness.
On F5 refresh, all resources on the page are conditionally re-validated via
If-Modified-Since. And, as we’ve seen, the resources on CloudFront are sent back missing the original
Cache-Control header, and IE updates its cache with just the
Expires tag, instead of keeping the resource still fresh for a year. For some reason, this doesn’t occur with Chrome or Firefox on F5.
In addition, the problem will appear in all browsers when they need to send a
If-Modified-Since header for re-validation of content they think might have expired, such as with max-age headers that have expired (shorter-expiring content).
Take, for example, a resource that you set to expire 1 day from now, and either set the
Expires header to 1 day from now (per best practices) or simply don’t specify the
Cache-Control: public, max-age=86400
For the first 24 hours after your visitor loads the resource, modern browsers won’t re-validate the resource. At hour 24 and 1 second, the browser will send a conditional request. Unfortunately, with CloudFront, the 304 response will be missing the
Cache-Control header. The browser then doesn’t realize that the resource should be fresh for another 24 hours. So even if the content wasn’t actually updated after those 24 hours, all subsequent navigations with the resource will trigger a conditional validate of that resource, since the original
Cache-Control headers were lost with the 304. Ugh.
How to Avoid the Issue
Note this doesn’t appear to affect Chrome 14 and FireFox 6 in the F5 scenario. Both browsers send conditional
If-Modified-Since headers on F5 and get back the same CloudFront response (sans
Cache-Control headers), but they don’t appear to be affected by the missing
Cache-Control header. Subsequent navigations in Chrome and FF after a F5 do not conditionally re-validate the CloudFront content. They do appear to be affected by the missing
Cache-Control header for naturally stale content on
I haven’t investigated the F5 problem on pre-IE9 versions, but I would assume the problem exists there as well. As far as I can tell, this isn’t fixed in IE10 beta.
I’ve only found this problem on CloudFront’s CDN servers. I couldn’t find a way to get Apache to naturally skip the
Cache-Control header for 304s if the header was in the original HTTP 200 response (for example, when using mod_expires on static content).
The bottom line is that requests that send an
If-Modified-Since to CloudFront and get a 304 back will essentially lose the
Cache-Control hints. If your
Expires header is missing, or in the past, the resource will be conditionally validated on every page navigation until it gets evicted from the cache. That can cause a lot of unnecessary requests and will slow down your visitor’s page loads.
The simple solution is to use a much-further expiry time. 30 years should suffice. Then, if the original
Cache-Control header is lost from CloudFront 304s, the 30-year-from-now
Expires header will keep the resource from having to be validated.
I’m not sure why Amazon CloudFront strips the
Cache-Control header from 304 responses. I’ll follow up with them.
Back to my original problem: I think it’s actually Amazon’s CloudFront servers noting that the
Expires for a lot of my static images are past-due. They’re checking the origin server to see if any new content is available. The above issue isn’t likely causing a ton of additional load, but it was interesting to find none-the-less!
Update 2011-10-12: I’ve opened a thread on the Amazon CloudFront forums here. The team has responded saying they’re looking into the issue.
Update 2011-12-18: The Amazon CloudFront team has fixed the issue!
Nice article, but it’d have been nice to say in the beginning that Amazon has fixed the issue! Now I read the whole page, and am grateful it taught me something new, but looks like I don’t have to worry about it anymore 😀
Good point, article updated!