Optimize with HTTP/2 Server Push and Service Workers!


HTTP/2 is being used in ~12% of the websites in the World Wide Web, but certainly the number is higher in the websites we visit the most. If you want to understand better how HTTP/2 works and how to debug it, take a look at our latest blogposts: HTTP/2: a look into the future of the web and Alternatives do HTTP/2!.

In this blogpost we are going to use one of the coolest features of HTTP/2: Server Push. In summary, server push allows the server to send data to the client right after the handshake, without the client requesting it (sending a PUSH_STREAM). The client still has the possibility to reject messages (sending a RST_STREAM) if it already has recent cached resources that can be used.

Service Workers are not new and there is plenty of information about them all over the web. To sum up, they have a specific lifecycle (installing, waiting, active, etc.) and allow, among other things, to intercept fetch requests and cache resources that will be used when the user goes offline. If you’re new to Service Workers, you can find more information about Service Workers at W3C — Service Workers Specification, Google — “Service Workers: an Introduction” or Mozilla — “Using Service Workers”.

Please take into consideration that not only Service Workers need HTTPS, but also HTTP/2 requires HTTPS to work on browsers.

You can find the code examples at this Github repo.

Adding HTTP/2

In the following examples we used the node-spdy module. There are some HTTP/2 solutions already for Node.js and, in general, the API is quite similar. The initial request (index) is the best opportunity to push resources from the server to the client, because after connection establishment, the server knows what resources the client will need.

In our example two files will be pushed along with the HTML response, style.css and app.js, which will respectively add styles to the page and register the service worker.

In the following example we added the index route and push styles.css to the response. We also added a validation to understand if we're using SPDY or not (basically it validates if we are using HTTP/2, see our blogpost about it for details). If we are not using HTTP/2, we can't use server push and have to find alternatives and a fallback to HTTP/1.x.

const styles = fs.readFileSync('assets/style.css');  
  
app.get('/', function (request, response) {  
  
  if (!request.isSpdy) {  
    return response.end('SPDY is off. We cannot use Server Push :(')  
  }  
  
  response.push('/style.css', {  
    response: {  
      'Content-Type': 'text/css'  
    }  
  }, function(err, stream){  
    if (err) {  
      return;  
    }  
    stream.end(styles);  
  });  
  
  // ...

Another consideration when using HTTP/2 is the importance of having a certificate, because modern browsers only support HTTPS. We generated our certificates for testing and added them to server creation, as follows:

const options = {  
  key: fs.readFileSync('keys/server.key'),  
  cert: fs.readFileSync('keys/server.crt'),  
  ca: fs.readFileSync('keys/server.csr')  
};  
  
const server = spdy.createServer(options, app);  
  
server.listen(3000, () => {  
  console.log(\`Server started on port ${server.address().port}\`);  
});

(Image: Initial load — take a look into the Initiator column where you can find the pushed resources. Because style.css was pushed, 'Hello, World!' message has a aquamarine background.)

More routes can be found in index.js file, namely for the Service Worker and support for other assets required. Take into account that we only served static files (e.g. app.use(express.static('public'))) after the declaration of the index route, because otherwise pushed resources wouldn't be pushed, but retrieved by Express.js first.

Adding Service Workers black magic

As we mentioned before, app.js registers the Service Worker in our page. We decided to cache some resources and intercept all fetches as you can observe in sw.js.

The install hook basically caches every URL we add to addAll function. In this case our list of URLs is as follows:

let urls = \[  
 '/',  
 'app.js',  
 'favicon.ico',  
 'picture.png',  
 'style.css'  
\];

You can add every URL you want to that list, but you have to be careful, because the more URLs you have, the higher the probability that one of them may fail. In the case of one failing request, the cache will not store anything. Having more than one cache may help to diminish this issue and it may also be the desired behaviour because, for example, you may want to separate images cache from other URL cache. Moreover, as a developer you should also be aware of user storage limits and limited mobile data plans (and do not cache everything!).

(Image: Cached resources — taking a look into Developer tools > Application > Cache Storage allows to understand what was stored in the cache and how many caches we have.)

We mentioned that Service Workers allow the interception of fetch events, and an example of it can be found in the following code:

this.addEventListener('fetch', function(event) {  
  var response;  
  event.respondWith(caches.match(event.request).catch(function() {  
    return fetch(event.request);  
  }).then(function(r) {  
    response = r;  
    caches.open(cacheName).then(function(cache) {  
      cache.put(event.request, response);  
    });  
    return response.clone();  
  }).catch(function() {  
    return caches.match('picture.png');  
  }));  
});

When the client tries to fetch a resource, the Service Worker will try to see if there’s a match inside any of the caches. If a match is found, it is going to be retrieved. Otherwise, the fetch is initiated and the server is contacted. If everything goes well and server can be contacted, the requested URL is going to be added to the cache (cache.put(event.request, response)). If the server can't be contacted due to some network issue, the Service Worker provides a fallback to picture.png.

You can test this behaviour turning off WIFI or setting offline mode in developer tools (Open developer tools > Application > Service Workers > Toggle Offline mode). old-picture.png is not being added to Service Worker cache and when we try to request the page again without connection to the internet, picture.png is retrieved instead:

(Image: Offline load — __picture.png is used as a fallback to old-picture.png, which was not stored in the cache.)

(Image: Offline load — All files were retrieved from the Service Worker.)

FAQ and Hints

1. These certificates don’t allow me to play with Service Workers

If you’re receiving the following message, it means that you don’t have a valid certificate.

(Image: Invalid certificate error.)

Quoting Mozilla documentation, “SHA-1 certificates will no longer be treated as secure by major browser manufacturers beginning in 2017.”. A solution to this is adding a valid and authorized certificate given by a Certificate Authority (CA).

For example, for Firefox, you can easily find a list of legitimate CA for Firefox. If you take a look at that list, you will see Let’s Encrypt there, which is a free and legitimate CA. There are several hosting providers that support Let’s Encrypt.

However, in the context of our blogpost and as example we did not want to setup a certificate. Our workaround to test this example was to open Google Chrome with --ignore-certificate-errors flag and set up our Service Worker with insecure flag. Please make sure you never use this in production.

navigator.serviceWorker.register('/sw.js', { insecure: true }) // don't do this

2. When I request the files for the first time and the Service Worker installs, it requests the files twice…

In our example, both style.css and app.js were pushed when index page is retrieved. But app.js registers the Service Worker and the Service Worker tries to cache some files using cache.addAll function. However, these files are requested again. We tried to add Cache-Control headers, but without success.

We observed that connection Id is different in the first request (the index page) and the rest of the files (that Service Worker requests). This is an important detail that should be documented because, by default in Chrome, two different connections are created in HTTP/2 for credentialed and non-credentialed requests. Both initial requested and pushed resources by/to the client are credentialed (e.g. the pushed app.js file), but picture.png or app.js in the Service Worker cache were not credentialed by default. An important note to take into account is that HTTP cache and HTTP/2 cache work in a different ways.

With Jake Archibald’s help, we added { credentials: 'include' } to our install hook in the Service Worker, which guaranteed that fetch requests were credentialed and same connection is used. For further details, please see this explanation.

There was also something else making our implementation retrieving the resources twice. The problem was related to the first hint, because when we open Chrome with --ignore-certificate-errors it won't put requested URL in the cache. Generating valid certificates and launching Chrome without that flag make everything work as expected.

The problem was that index was requested by window (first request), and style.css was pushed into HTTP/2 cache. So far so good!... but style.css is included in the index file and when it is going to be requested (by window) it should use the HTTP/2 cache. It didn't happen because the certificate was not legitimate. When style.css is fetched by the Service Worker, the file is no longer in the HTTP/2 cache nor HTTP cache. That's why it resulted in a new fetch. With a valid certificate, { insecure: true } removed from Service Worker registration, { credentials: 'include' } in the Service Worker install hook and caching headers to correspondent resources, everything works fine.

(Image: Working example with a valid certificate and cached resources — The two pushed resources are cached and later retrieved from disk cache, when Service Worker tries to request them.)

3. Why we decided to use Express.js instead of Hapi.js in this example

For the purpose of this demonstration, we decided to use Express.js framework. The reason behind it is because Express request and response are exactly the same HTTP objects provided by Node.js. In the examples we decided to use node-spdy module. On the other hand, if you are using Hapi.js, which we strongly recommend for large projects and teams, there are already solutions for it (follow HTTP/2 thread on Hapi.js community).

Final thoughts

Try it out!

Using HTTP/2 Server Push and Service Workers together may help you to optimize your website load and figure out how to deliver the best possible content to your clients. Don’t forget to checkout and play with our code example at Github and contact us if you need help.

Acknowledgments:

Thanks to Jake Archibald (@jaffathecake on Twitter) for helping debugging.

Written by Daniela Matos de Carvalho — Consultant at YLD.

Interested in HTTP? Read more about it:


Written by YLDMarch 3rd, 2017


Share this article