Forcing a browser CSS cache reload with HTTP/2 Server Push
CSS Caching Issues
A common problem for web developers is that when CSS is updated on the server, users browsers do not always download the updated CSS. This causes the user to see the outdated CSS. Depending on the changes, this can cause either minor or major issues when displaying the page.
In the past, the simplest way to fix it was changing the <link>
tag to include the file's modification date:
<link rel="stylesheet" href="style.css?<?=filemtime('style.css');?>" />
This solution is easy to implement but isn't without its problems. If your website uses a cache for the generated HTML code for your pages, the cache must be invalidated each time the CSS file is changed so it can be regenerated with the new timestamp.
A bigger problem, though, is that the browser will see the stylesheet URL as style.css?1552821764
. While this solution works, on modern sites which make use of HTTP/2 Server Push, this does not have the intended result.
What is HTTP/2 Server Push?
If you know about server push click here to skip to the description of the cache problem and solution
For those unfamiliar with server push, here's a very quick overview. Without server push, the browser connects to, for example, index.html
. It parses the HTML file and sees the <link>
tag which references style.css
. The browser then makes a second request to the server to download the CSS file.
Although this does happen very quickly, the second trip to the server can result in a small delay before the CSS is applied and slows down the overall page loading speed.
HTTP/2 Server Push fixes this by allowing developers to configure the server to send style.css
in the same request as index.html
.
This is done with the link
header. When the user connects to index.html
, the following header can be sent:
Link: </style.css>; as=style; rel=preload
When this header is set the web server sends the contents of index.html
and style.css
in a single request. You can think of it a bit like an attachment in an email, the link header tells the server to attach style.css
to the request to index.html
.
In the single request, the browser receives something like the following:
FILE: </style.css>
body {
background-color: red;
}
FILE: </index.html>
<html>
<link rel="stylesheet" href="style.css" />
<body>
...
Note: this is not the actual request the browser sees, just a crude demonstration!
When the browser parses index.html
, it sees the reference to style.css
and then looks for the section FILE: </style.css>
in the initial request, if it exists, it uses the file it was already sent. If it can't find a FILE: </style.css>
block it continues as normal and sends a second request to the server to download style.css
.
Cookies
A consideration with HTTP/2 Push is that you don't want to push the stylesheet on every request. Ideally style.css
should be sent the first time someone views a page, then cached by the browser. Following requests to pages on the server will avoid pushing the CSS again.
The approach suggested by NGINX is:
- Check if a cookie is set
- If no cookie is set, push the stylesheet and set the cookie
- If the cookie is set, don't push anything
This has the effect of pushing the CSS on the first request and then assuming the browser has it cached on subsequent requests.
Cache Concerns
A problem occurs with browser caching. The browser caches the pushed style.css
and, as is a common problem, sometimes the browser will not re-download the CSS if it's cached even though the file has changed on the server.
If you use the timestamp approach:
<link rel="stylesheet" href="style.css?<?=filemtime('style.css');?>" />
HTTP/2 Push doesn't work as intended. The browser has been pushed style.css
but the URI in the link tag is style.css?1552821764
. Because the URIs are different (one has the timestamp, one does not) the pushed style.css
is ignored and second request is sent. This is a worst of all worlds approach. The CSS is getting sent twice, once through push and once in a second request.
This is actually worse than not using push because the pushed data is never used and becomes a complete waste of bandwidth.
An alternative approach is required. With HTTP/2 Push it would be good to selectively re-push style.css
if the file has been modified on the server.
This requires storing the time the file was originally downloaded and comparing it to the time the file was modified on the server.
Unfortunately, it doesn't seem possible to check the modified time of a file using NGINX. Please let me know if there is a way to achieve this.
That leaves us with doing this in PHP (or whatever scripting language you are using).
PHP Solution
Here's a class which handles this by using cookies:
namespace HTTP2Push;
class CacheAware {
private $htdocs;
private $files;
public function __construct(string $htdocs, array $files = []) {
$this->htdocs = rtrim($htdocs, \DIRECTORY_SEPARATOR);
$this->files = $files;
}
private function assetsChangedSince(int $time): array {
$changed = [];
foreach ($this->files as $file => $type) {
if (filemtime($this->htdocs . \DIRECTORY_SEPARATOR . $file) > $time) {
$changed[$file] = $type;
}
} return $changed;
}
public function getHeader($time): string {
$changedFiles = $this->assetsChangedSince($time);
return count($changedFiles) === 0 ? '' : $this->createHeaderString();
}
private function createHeaderString() {
$parts = [];
foreach ($this->files as $file => $type) {
$parts[] = '</' . $file . '>; as=' . $type . '; rel=preload';
}
return 'Link: ' . implode(', ', $parts);
}
}
Using the class
You can then use the class like this:
// List of files to push and their type for "as="
$files = [
'test.css' => 'stylesheet',
'logo.png' => 'image'
];
// Requires the relative path to the public/httpdocs directory and a list of files to push
$http2push = new \HTTP2Push\CacheAware('./public', $files);
// read the cookie, if it's not set, use 0 to force push
$lastDownloadTime = $_COOKIE['http2push'] ?? 0;
// Get the link header. This will either be the HTTP header or an empty string depending on $lastDownloadTime
$header = $http2push->getHeader($lastDownloadTime);
if ($header !== '') {
// If there are files to be pushed, set the cookie to log the download time
setcookie('http2push', time());
// Then send the header
header($header);
}
The class is purposefully decoupled from the cookie variable so that you can use sessions, browser footprints, etc to keep track of when each user last downloaded each file.
In an ideal world this could be handled at the browser/server level. Unfortunately at the moment that's not possible so this is the only workable solution.
Conclusion
Although this approach requires a lot more code than the much simpler timestamp solution, it solves the underlying problem without needing to modify the HTML document. If your website uses a static caching layer for the HTML, the cache does not need to be invalidated when the stylesheet is updated to recreate the cache with the new timestamp.