loading...

Webp-ing your site: reduce image file size, increase site performance

gksander profile image Grant Sander ・10 min read

TL;DR

The .webp image format can offer drastic improvements in image file size... I'm talking up to 90% reduction in size (from their .jpg or .png counterparts). Aside from Safari and IE, .webp images have broad browser support, and with tools like sharp, you can easily take your collection of .jpgs and .pngs and convert them to .webp images to serve to browsers that can handle them. It's relatively straight-forward to detect browsers that don't support .webp, and I'll show you one way to handle those browsers.

The code for this example can be found on GitHub.

Back story

I'm a software engineer that works in the graphics/printing industry, so I deal with images... a lot. Most applications I build involve a lot of image (users uploading and editing them, and displaying large collections of user-uploaded content). It's in our best interest to optimize images for the web when displaying them back to users (reduced bandwidth), but more importantly - it can drastically improve site performance and page load speed (since significantly less data is being sent to the user). Recently, I transitioned to using .webp whenever possible, and I'll show you some of the tricks I picked up.

Want to follow along?

If you want to follow along, check out the code on GitHub. You can clone the repository and then run an npm install from the root of the folder (there's only one node module needed, but it's an important one).

How do I create .webp images?

sharp. That's how.

There's a decent chance that nobody has ever emailed you a .webp image file, or you've never opened one with a graphics application. So is it a total pain in the arse to transition into using .webp images in your site? At first, I thought so. Turns out, it's not that bad.

Since I regularly deal with large user-uploaded image files, I'm used to converting images into web-ready versions. My back-end technology of choice is Node, and luckily there's an amazing node library for processing images: sharp. Sharp is insanely fast at manipulating images. You can resize and crop a photo in milliseconds. It's a life-saver for me.

You can feed sharp a variety of image types (.jpg, .png, .tiff, etc.), manipulate the image, and output it in various formats - including .webp. I generally use sharp to web-ify user-uploaded images, but it's equally as useful in writing scripts to process your own files. I'll show you one way to use sharp to make .webp copies of your site's images.

If you have my sample repository opened, you can see there's a folder titled /images that contains some images. The only "original" images are dog.jpg and chair.png. We're going to generate the other files.

There's also a file called process-images.js that has the following content:

// Import sharp (processing images) and path (traversing directory)
const sharp = require('sharp');
const path = require('path');

// Create an asynchronous IIFE
(async function(){
  // Where are our image files located?
  const imageDirectory = path.join(__dirname, './images');
  // Which images should we process?
  const imageNames = ["dog.jpg", "chair.png"];
  // What size should we crop to?
  const maxSize = 1000;

  // Loop through the images and process them one at a time.
  for (let imageName of imageNames) {
    try {
      // Start by creating a jpg version
      await sharp(path.join(imageDirectory, imageName)) // This inputs the file into sharp
        .resize(maxSize, maxSize, { fit: "inside" }) // This resizes our image
        .toFile(
          path.join(imageDirectory, imageName.replace(/\.(jpg|png)$/, `_${maxSize}$&`)) // Replace file extensions with .jpg (assumes .jpg or .png)
        ); // This writes the new image.

      // Same thing, but create a .webp version
      await sharp(path.join(imageDirectory, imageName))
        .resize(maxSize, maxSize, { fit: "inside" })
        .toFile(
          path.join(imageDirectory, imageName.replace(/\.(jpg|png)$/, `_${maxSize}.webp`)) // Replace file extensions with .webp (assumes .jpg or .png)
        ); // This writes the new image.

    } catch (_) {}
  } // End loop

  process.exit();
})();

This is the script that will take our "original" image files and create .webp versions of them. Here's what's going on in this file:

We import the path and sharp node modules (path is a native one). Then, we're going to run a function to process our files. At the top of that function you'll see:

// Where are our image files located?
const imageDirectory = path.join(__dirname, './images');
// Which images should we process?
const imageNames = ["dog.jpg", "chair.png"];
// What size should we crop to?
const maxSize = 1000;

This sets some values to use, such as where the files are stored (the imageDirectory variable), what image files to process (the imageNames array), and what size to crop them to (maxSize). Then, we'll loop through each of the files in imageNames and process them.

We'll start by just resizing the "original" image:

await sharp(path.join(imageDirectory, imageName)) // This inputs the file into sharp
  .resize(maxSize, maxSize, { fit: "inside" }) // This resizes our image
  .toFile(
    path.join(imageDirectory, imageName.replace(/\.(jpg|png)$/, `_${maxSize}$&`))
  ); // This writes the new image.

This feeds the image into sharp, tells sharp to resize it, and then outputs the file. The scary expression

imageName.replace(/\.(jpg|png)$/, `_${maxSize}$&`)

Just tells sharp to add a "_1000" before the file extension, so dog.jpg will become dog_1000.jpg and chair.png will become chair_1000.png.

We'll run a similar process, but add a .webp extension to the file. Sharp will automatically write that file as a .webp file - which is where the magic happens. For each of our "original" files, we should have a cropped variant, as well as a cropped .webp variant - all in the same folder.

Once we've got the script written, we need to run the following command from a command line:

node process-images.js

That's all it takes to process our files! As a fun extension, you could easily extend that script to create multiple different sizes of each image (say, one for a thumbnail and one for a "hero" shot).

Using our new images

Most browsers support .webp images - but Safari and IE do not. I think Safari has a large enough browser market-share to justify having a fallback for browsers that don't support .webp images (I try to pretend like IE doesn't exists anymore, but this should handle IE as well). For illustrative purposes, I'm going to display a simple Vue "app" that will show .webp images when possible, and fall back to .jpg or .png when necessary.

In the code repo, you'll find a file index.html which contains very bare HTML and a sprinkle of Vue to show how you could sprinkle in the .webp images. The index.html file contains a tiny bit of HTML:

<div id="app">
  <h1>Webp supported: {{ webpSupported ? 'Yes' : 'No' }}</h1>

  <!-- Show the chair photo -->
  <img
    :src="transformImgExt('/images/chair_1000.png')"
    width="150px"
  />

  <!-- Show the dog photo -->
  <img
    :src="transformImgExt('/images/dog_1000.jpg')"
    width="150px"
  />

</div>

The img tags is where we'll display our new images. If you're not familiar with Vue.js, the :src attribute of the image tags indicates to Vue that we want to have a dynamic src attribute with the value given. We're going to write a function transformImgExt that will take an image URL and replace it with a .webp version if appropriate. So for example, transformImgExt('/images/chair_1000.png') will give us the relative url for /images/chair_1000.png, but try to replace it with /images/chair_1000.webp if the browser supports .webp images.

Detecting browser support

Let's dig into the JavaScript we'll need to detect support for .webp. Here's the JS in the index.html file. (If you aren't familiar with Vue.js, don't worry too much about the details.)

let app = new Vue({
  // What should we mount our Vue instance to?
  el: "#app",

  // App data
  data: {
    // We'll initially assume webp is supported
    webpSupported: true
  },

  // Methods
  methods: {
    /**
     * Helper to transform image extension.
     * Checks if webp is supported, and will swap out the image extension accordingly.
     */
    transformImgExt (url) {
      // If webp is supported, transform the url
      if (this.webpSupported) {
        return url.replace(/\.\w{1,5}$/, ".webp");
      } else { // Otherwise, just return the original
        return url;
      }
    }
  },

  /**
   * When app is "created", we'll run some checks to see if the browser supports webp
   */
  created() {
    (async () => {
      // If browser doesn't have createImageBitmap, we can't use webp.
      if (!self.createImageBitmap) {
        this.webpSupported = false;
        return;
      }

      // Base64 representation of a white point image
      const webpData = 'data:image/webp;base64,UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoCAAEAAQAcJaQAA3AA/v3AgAA=';
      // Retrieve the Image in Blob Format
      const blob = await fetch(webpData).then(r => r.blob());
      // If the createImageBitmap method succeeds, return true, otherwise false
      this.webpSupported = await createImageBitmap(blob).then(() => true, () => false);

    })();
  } // End created

})

You'll see a data property in the code:

// App data
data: {
  // We'll initially assume webp is supported
  webpSupported: true
}

This is our "application" state. We'll create a state property called webpSupported to hold a boolean indicating whether or not we've got support for .webp. We'll potentially change this value once we do some "sniffing" to see if our browser can handle the .webp images.

Next, let's jump down to the created() section:

/**
 * When app is "created", we'll run some checks to see if the browser supports webp
 */
created() {
  (async () => {
    // If browser doesn't have createImageBitmap, we can't use webp.
    if (!self.createImageBitmap) {
      this.webpSupported = false;
      return;
    }

    // Base64 representation of a white point image
    const webpData = 'data:image/webp;base64,UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoCAAEAAQAcJaQAA3AA/v3AgAA=';
    // Retrieve the Image in Blob Format
    const blob = await fetch(webpData).then(r => r.blob());
    // If the createImageBitmap method succeeds, return true, otherwise false
    this.webpSupported = await createImageBitmap(blob).then(() => true, () => false);

  })();
} // End created

This is using a technique I found in this article. This snippet checks to see if the browser has a createImageBitmap method - if not, .webp is not supported. Then, we'll create a base64 encoded webp image as a blob and try to create an image bitmap from it. If we can, then the browser supports .webp. There are some technical details behind that, but that's beyond the scope of this post.

At the end of the snippet, you'll notice the statement:

this.webpSupported = await createImageBitmap(blob).then(() => true, () => false);

The RHS of the statement tries to create the image bitmap, and if it's successful the () => true function expression will run (returning true), otherwise the () => false function expression will run (returning false). This gives us a value for this.webpSupported, which references that application state property we looked at earlier. At this point, our data.webpSupported property holds a boolean that actually tells us whether or not our browser supports .webp images.

We've got one last thing to look at: the transformImgExt method:

/**
 * Helper to transform image extension.
 * Checks if webp is supported, and will swap out the image extension accordingly.
 */
transformImgExt (url) {
  // If webp is supported, transform the url
  if (this.webpSupported) {
    return url.replace(/\.\w{1,5}$/, ".webp");
  } else { // Otherwise, just return the original
    return url;
  }
}

This method will take a url, and if .webp is supported it'll swap out the file extension for .webp. Otherwise, it'll just give you the url back.

Let's inspect the line

return url.replace(/\.\w{1,5}$/, ".webp");

a bit further though. If you're not familiar with Regular Expressions in JS, this probably looks like random characters. We're using the string "replace" method. The /\.\w{1,5}$/ is a regular expression that looks for filename extensions. The \. at the start indicates a ".", the \w{1,5} will look for 1 to 5 letters (word characters?), and the $ at the end indicates that it should be at the very end of the string. If we find something that matches, we'll replace what we found with ".webp". This should replace ".jpg" or ".png" with ".webp". (Be careful! This will also transform ".pdf" to ".webp". You could tighten this down more, if needed.)

Now, we can use the transformImgExt to try to serve up a .webp image extension if our browser can handle it. We saw this earlier.

See it in action

I put these resources in a CodePen to showcase this. (The image url's are different because they were uploaded through CodePen.) If you open the pen in Chrome, FireFox, or Edge you should see that the images are indeed .webp. Right click on one and inspect it, or open the image in a new tab and notice that it is indeed a .webp image. If you open the pen in Safari or IE, you should see that the images are .jpg or .png images instead.

How much did we save?

The way .webp files are compressed is pretty neat, but your benefit will vary from image to image. Let's look at the savings from this example.

  • dog_1000.jpg has a size of 122 KB. dog_1000.webp has a size of 90 KB. That's a 25% savings. Not bad!
  • chair_1000.png has a size of 778 KB. chair_1000.webp has a size of 81KB. That's an 89.5% savings. That is amazing.

In real-world use, I'm getting on average somewhere between 40-60% savings. .png images seem to provide the most savings, and .webp images support transparency!

Closing comments

.webp is neat. There are some potentially huge file-size savings for using them. However, it takes a little work to get them integrated into a site. Here are some miscellaneous closing comments relative to this.

  • With sharp, you can control various aspects of the "to-webp" conversion, such as whether you want the compression to be lossless or not, and so on.
  • Sharp is extremely fast, so it's inexpensive to do these types of conversions.
  • I've been "webp-ing" static site assets as shown above, but also "webp-ing" user uploads. Generally, when a user uploads a file I'll create a resized .jpg version, as well as a resized .webp. Sharp tears through these in a lightning-fast fashion.
  • I generally create a method such as the transformImgExt shown above, but expose it throughout my app and use it wherever I'd like to display .webp images. This makes it reusable, and isn't that much work to factor the new image format into your app (with fallback support for Safari and IE).

Posted on by:

gksander profile

Grant Sander

@gksander

Software engineer focused on using JavaScript/TypeScript to build full-stack web and mobile apps.

Discussion

pic
Editor guide
 

Hey Grant!

Great post 👍

You could leverage the power of the picture HTML tag combined with multiple source tags and the standard img tag to let each browser decide which image to use.

Would be something like this, even with display density options:

​<picture>
 <source srcset="d​og_1000.webp 1x, dog_2000.webp 2x, dog_3000.webp 3x">
 <img src="dog_1000.png" alt="Hello yes this is dog">
</picture>

With that, you wouldn’t need the JS webp support check - browsers that understand picture/source will choose an appropriate image. Others fall back to the img :)

 

Thanks Grant, awesome piece.

 

For those looking for webp browser support: keycdn.com/support/webp-support