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 .jpg
s and .png
s 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 of81KB
. 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).
Top comments (4)
Hey Grant!
Great post 👍
You could leverage the power of the
picture
HTML tag combined with multiplesource
tags and the standardimg
tag to let each browser decide which image to use.Would be something like this, even with display density options:
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
:)I was just doing some work with WebP images (after having not touched them in awhile). Came back to this article, and this comment is very helpful - and what feels like the right way to do this! (Instead of using JS, let the platform handle the support detection.) In my case, I created a Vue component that wraps my
picture > source + img
tree accordingly - and works like a charm!Thanks Grant, awesome piece.
For those looking for webp browser support: keycdn.com/support/webp-support