In the never ending quest to build the fastest websites possible, I am always looking for sneaky ways to return the lightest and most optimal resources to my web pages. As web developers, we’ve never been luckier with regards to the power that our browsers have today. We have the ability to tailor the resources that we return to our users based on their browser, device memory and even network connection.

The thing is, making major changes to a large codebase can be difficult. Let’s face it, it would be nice to overhaul your code completely every time new a technology comes along, but in reality it takes a long time and it’s often never going to get done. This is where service workers shine. They allow us to intercept network requests and modify responses – which is perfect for such a scenario. If you aren’t familiar with the basics of service workers, I recommend reading the following article. In this article, I am going to show you how I used a service worker to return the optimal image for a given browser, device and network connection.

Performance Comparison

I have to admit it, I can be a little lazy at times. When it comes to optimising images, I often like to use a cloud based image management solution such as Cloudinary that takes all of the hard work away. Imagine that you have a JPG image on your site, you simply point it to Cloudinary and it will optimise it on-the-fly and return a lightweight version of the image in the best possible format for your browser (think WebP, JPEGXR, JP2000, etc). In terms of web performance, this makes life a whole lot easier for web developers looking to get the best out of a large number of images.

Before we go any further, it is worth pointing out the difference that images that have been optimised and returned in the best possible format for your browser can make. Let’s compare the two images below.

Image Comparison

The image on the left is the original JPEG and comes in at around 97 KB. The image on the right has been optimised and comes in at 53 KB. That’s a savings of 44 KB on an image!
It’s worth pointing out that the image has been optimised by Cloudinary and returned as a WebP image. This optimised image has had it’s quality index reduced without compromising visual fidelity. Imagine the difference this could make to an entire web page filled with images.

In my example, I want to rewrite my URLs from the following:

<img src="/dog.jpg" />

To update and point it to:

<img src="https://res.cloudinary.com/hume/image/fetch/q_auto,f_auto/dog.jpg" />

This will return the optimal image and format. The thing is, this does exactly what I want it to do, but I really don’t want to have to update all parts of my site. This is where a service worker is the perfect solution!

Getting Started

With all of this in mind, let’s imagine a web page with a single image.

<img src="./dog.jpg">

<script>
// Register the service worker
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('./service-worker.js').then(function(registration) {
    // Registration was successful
    console.log('ServiceWorker registration successful with scope: ', registration.scope);
  }).catch(function(err) {
    // registration failed 🙁
    console.log('ServiceWorker registration failed: ', err);
  });
}
</script>

Which will produce a web page that looks a little like this.

Mobile Screenshot

We’ve registered a service worker for the page using the default registration code. Next, we need to create a file and call it ‘service-worker.js‘ – which is where our service worker code will reside.

Let’s update the service worker code to tap into the fetch event and check if the current request is for an image.

  
self.addEventListener('fetch', event => {
  // Check if the request is for an image
  if (/\.jpg$|.png$|.gif$/.test(event.request.url)) {
    
    const cloudinaryUrl = `https://res.cloudinary.com/hume/image/fetch/q_auto,f_auto/${imageUrl}`;
    
    const fetchPromise = fetch(cloudinaryUrl);

    // Try and fetch the image / timeout if too slow
    event.respondWith(fetchPromise);
  }
});

In the code above, we are checking if the current request is for an image, and if so, updating the URL. This will intercept the request and return the optimised image, which should be lighter and more efficient. This means that the service worker does all of the hard work and we don’t need to update the HTML all over our site.

The code above is great, but I want to take this a bit further to make it bulletproof by including:

  • The ability to handle any timeouts
  • Logic to handle any network errors that might occur
  • The ability to fallback to the original image request if anything goes wrong

Let’s update the code to take this into account.

"use strict";

/**
 * Fetch the image from Cloudinary and
 * fail if there are any errors.
 * @param {string} imageUrl
 */
function fetchNewImageUrl(imageUrl) {

   const controller = new AbortController();
   const signal = controller.signal;

   // Build up the Cloudinary URL
   const cloudinaryUrl = `https://res.cloudinary.com/hume/image/fetch/q_auto,f_auto/${imageUrl}`;

   const fetchPromise = fetch(cloudinaryUrl, { signal });

   // 5 second timeout
   const timeoutId = setTimeout(() => controller.abort(), 5000);

   return fetchPromise
       .then(response => {
           if (!response.ok) {
               // We failed return original image
               return fetch(imageUrl);
           }
           return response;
       })
       .catch(error => {
           // Something went wrong, return original image
          return fetch(imageUrl);
       });
}

self.addEventListener('fetch', event => {

   // Check if the request is for an image
   if (/\.jpg$|.png$|.gif$/.test(event.request.url)) {

       // Try and fetch the image / timeout if too slow
       event.respondWith(fetchNewImageUrl(event.request.url));
   }
});

Woah – this looks like a lot of code! Let’s break it down a little.

In the code above, I’ve created a function called fetchNewImageUrl() which takes in the URL of the current image request. Next, I want to be sure that I can force the request to timeout if it takes too long. If this happens, we want to fetch the original image instead.

Finally, we’ve added an event listener that taps into the fetch event which checks to see if the current request is for an image and if it is – it will call fetchNewImageUrl() and return the optimal image for the user based on the current browser.

Advanced use with Save-Data, Device Memory and Connection Type

If you are optimising your website for mobile devices, it is always important to take into account things such as the device memory, their connection type or even any data saving settings they might have enabled. For example, imagine if a user to your site is on a low end device with only a 2G connection – in this case it’s best to return a low quality image that is best suited to their device and connection type.

Believe it or not, this information is actually available to us as developers right now. Using a sneaky trick I learnt from Colin Bendell, lets tap into this information and return images that are as lightweight as possible.

function shouldReturnLowQuality(request){
   if ( (request.headers.get('save-data')) // Save Data is on
     || (navigator.connection.effectiveType.match(/2g/)) // Looks like a 2G connection
     || (navigator.deviceMemory < 1) // We have less than 1G of RAM
   ){
     return true;
   }

   return false;
}

In the function above, we are checking a number of details about the user’s device and their network connection. We can do this by inspecting navigator.connection.effectiveType and navigator.deviceMemory to determine their network connection and current memory on their device. If you would like to learn in more detail about navigator.connectionType and service workers, I recommend reading this article for more information.

That’s it – if we start calling this code on a device with low memory or network connection, it will return us an image that looks a little like the one below. I’ve dialed the quality of this image right down, but you could tailor this to the needs of your users. This is great because you are able to present the user with a fully usable web page as quickly as possible, giving them a much better experience.

Low quality image

Summary

As web developers, it’s important that we provide our users with the best experience possible. Whether this means providing them with fast, responsive web pages, or tailoring their experience based on their device – this is all possible with the power of service workers and modern browsers. Whether you decide to use a service like Cloudinary or roll your own, serving users with lightweight images makes all the difference to web performance.

The best thing about this code is that you don’t have to change every link on your site – the service worker handles all of this complexity for you! If you’d like to see a working version of this, please head over to deanhume.github.io/pwa-cloudinary or if you’d like to see the associated code, please head over to github.com/deanhume/pwa-cloudinary/.

A massive thank you to Colin Bendell and Robin Osborne for their help reviewing this article!

ABOUT THE AUTHOR

Dean Hume

Dean Hume (@DeanoHume) is a software developer and author based in London, U.K. He is passionate about web performance, and he regularly writes on his blog - deanhume.com.

Comments are closed.