Lighthouse: improve scores with next-gen image optimization

Kurt Mackey
Fly.io
Published in
11 min readJun 28, 2018

If you live and breathe web performance, chances are you’ve already heard about the wizardry that is “Google Lighthouse”. If not, by the end of this article you’ll understand why it’s an absolutely necessary tool for testing and improving web performance.

Lighthouse is an Open Source auditing tool from the folks over at Google that we can use to help increase the quality of our web pages and applications. Audits are conducted in a few key areas: Performance, Progressive Web App, Accessibility, Best Practices, and SEO. These audits give you an excellent overview of your site and paint a comprehensive picture of how well your site is performing and what you can do to improve it.

Lighthouse scores range from 0–100, where anything above an 80 is considered “good”, and anything less requires your undivided attention right now. Yes, put down that hot dog, we have work to do. But don’t worry, this won’t take long at all. Throughout this “Google Lighthouse Series”, I’m going to explain how you can pass each individual test in Lighthouse with the hopes of obtaining a perfect score!

How to run an audit

You can run an audit against any page using the Chrome extension, or directly within your developer tools (Inspect > Audits > Perform an audit).

I should also mention that these tools are not only super powerful for developers, but Product Managers and Quality Assurance teams are finding these audits helpful for quickly and easily assessing and understanding the current state of performance of their sites.

Like I said, Lighthouse categorizes audits into a few different sections. In this part of our Lighthouse series, we’re going to talk about performance. Specifically, were going to talk about images and how you can easily serve images in next-gen formats for better compression, faster downloads, less data consumption, and better scores of course! Let’s dive in.

Performance is the backbone of user experience

Unfortunately, performance is so often overlooked. Maybe we turn a blind eye to performance because we’re frightened by all of the little individual puzzle pieces that make up a well performing site. Well, I have good news for you! With Fly, tackling performance issues is actually less daunting, more approachable and totally doable.

When you run a Lighthouse report, you’ll see the five individual areas being evaluated … the first being performance. This section scores your app’s current performance and showcases areas of improvement that you might be missing out on. First, you’ll see metrics explaining exactly how well your site is performing. Then you’ll see a list of opportunities, meaning things you can do now to directly speed up your site. More times than not, you’ll see the recommendation, “Serve images in next-gen formats”. Let’s take a closer look at this.

What are next-gen formats?

Next generation formats (in terms of images) are really just the newest technologies available for better looking graphics and faster downloads.

Did you know that image sizes account for a whopping 60–65% of your page? As a result, images are actually the primary source of page speed slowdown, that is, if you’re serving them wrong. This is actually the reason why Google has taken the initiative to promote the use their WebP format, to support their own effort to make the web faster.

WebP is a next-gen image format used primarily for speed. It has superior compression and quality characteristics, compared to its older, soon-forgotten-about pals, JPEG and PNG.

What makes WebP unique is the way in which it converts images. It encodes images in a very clean and efficient manner, which ultimately makes them considerably smaller than PNGs and JPEGs, without compromising quality, making it a good choice in most cases.

If you’ve ever worked with images, you’ve probably experienced just how tricky it can be to find the right balance between file size and quality. Using WebP, we can create smaller, richer images that reduce page load times by compressing our image’s data without losing important information and valuable quality.

In the remainder of this article, I’ll be explaining how you can convert last generation’s image formats, such as JPEG and PNG, to the chic and modern WebP format, providing you with faster page loads and, you guessed it, better Lighthouse scores. You’ll even see an astounding before and after if you stick around.

Correctly implementing WebP

WebP is currently only supported in Google Chrome and the Opera browser, so it’s important to only serve WebP images if the user’s browser supports it, otherwise you’ll run into a ton of errors and probably won’t serve your images at all!

Create WebP on-the-fly and size images properly for various viewports

The example below presents an automatic WebP conversion library created by the Fly team. In a nutshell, it searches your site for images and converts your images to the WebP format if your user’s browser supports it.

// images.js 
export function processImages(fetch, config) {
return async function processImages(req, opts) {
const url = new URL(req.url)
const accept = req.headers.get("accept") || ""
const sizes = (config && config.sizes) || {}
let webp = false

let vary = []
// figure out if we need a new size
let width = undefined
if (url.search) {
const key = url.search.substring(1)
const s = sizes[key]
if (s && s.width) {
width = s.width
vary.push(`w${width}`)
}
}
if (accept.includes("image/webp") && req.headers.get("fly-webp")
!== "off") {
vary.push("webp")
webp = true
}
// generate a cache key with filename + variants
const key = ["image", url.pathname].concat(vary).join(':')
console.log(key)
let resp = await fly.cache.get(key)
if (resp) {
resp.headers.set("Fly-Cache", "HIT")
return resp
}
// cache miss, do the rest
req.headers.delete("accept-encoding") // simplify by not having
to inflate
resp = await fetch(req, opts)
const contentType = resp.headers.get("content-type") // skip a bunch of request/response types
if (
resp.status != 200 || // skip non 200 status codes
req.method != "GET" || // skip post/head/etc
(!contentType.includes("image/"))
) {
return resp // don't do anything for most requests
}
// if we got here, it's an image let data = await resp.arrayBuffer() if (webp && contentType.includes("image/webp")) {
// already webp, noop
webp = false
}
if (webp || width) {
let image = new fly.Image(data)
if (width) {
image.withoutEnlargement().resize(width)
}
if (webp) {
image.webp()
}
const result = await image.toBuffer()
data = result.data
}
resp = new Response(data, resp)
if (webp) resp.headers.set("content-type", "image/webp")
resp.headers.set("content-length", data.byteLength)
await fly.cache.set(key, resp, 3600 * 24) // cache for 24h
resp.headers.set("Fly-Cache", "MISS")
return new Response(data, resp)
}
}

Let’s break this down piece by piece

export function processImages(fetch, config) { 
return async function processImages(req, opts) {
const url = new URL(req.url)
const accept = req.headers.get("accept") || ""
const sizes = (config && config.sizes) || {}
let webp = false

First, we are defining a processImages() function which will take two arguments, fetch and config. The param we will eventually be passing into fetch will be a proxy fetch for our site.

Then we are declaring the async function. We will later be asynchronously awaiting the response of our request object here.

Our url variable will be equal to the current window's URL, so that we can later search for images.

accept will grab all of the accepted headers for this request. The accept request HTTP header lets us know which content types the browser is able to understand (text/html, image/webp, etc...).

sizes will be used for resizing the images if need be.

Our webp variable starts off false so that we can make it true if image/webp is accepted by the browser. Ultimately, if it's true, it will mean that this format is accepted and we can convert images to webp.

let vary = [] 
// figure out if we need a new size
let width = undefined
if (url.search) {
const key = url.search.substring(1)
const s = sizes[key]
if (s && s.width) {
width = s.width
vary.push(`w${width}`)
}
}

vary will be an empty array that we will later push the word "webp" into (if it's accepted) and a width into (if the image needs to be resized), for the purpose of generating cache keys that match up with images.

Then, we’re figuring out if we need a new size for our image.

set width to undefined so that we can change it later and check if we need a new size or not.

Next, check if the window’s current URL has search params. If it does, set the variable key equal to the search params.

If we need a new size for our image, it will be stored as a number (in pixels) inside of our width variable.

if (accept.includes("image/webp") && req.headers.get("fly-webp") !==
"off") {
vary.push("webp")
webp = true
}

Push “webp” into the vary array if the browser accepts webp and if "fly-webp" is not disabled for some reason. Then, make webp true so that we can later convert our images.

// generate a cache key with filename + variants 
const key = ["image", url.pathname].concat(vary).join(':')
console.log(key)
let resp = await fly.cache.get(key)
if (resp) {
resp.headers.set("Fly-Cache", "HIT")
return resp
}

Set our key variable equal to the current window's pathname + whatever info we pushed into vary.

Then, set the variable resp equal to the value that is in the cache at that key, if any.

If there is a value in the cache at this key, we will serve that value to the user and set our Fly-Cache header to HIT, indicating that this response is being served from the cache.

If resp is null, this means that the cache is empty at this key and we will continue with our function.

// cache miss, do the rest 
req.headers.delete("accept-encoding") // simplify by not having to inflate
resp = await fetch(req, opts)

const contentType = resp.headers.get("content-type")

accept-encoding indicates which compression formats the browser is able to understand (gzip, deflate, br, etc...). We will make this null by deleting it.

Then, we’ll fetch the request object and get the content-type. The request will be whatever site we pass into our function.

// skip a bunch of request/response types 
if (
resp.status != 200 || // skip non 200 status codes
req.method != "GET" || // skip post/head/etc
(!contentType.includes("image/"))
) {
return resp // don't do anything for most requests
}

In this step, we’re basically ending here and returning the response if the response is not a 200 status, a get request, or an image. Basically, we’ll call it quits here if there’s nothing to convert.

// if we got here, it's an image 
let data = await resp.arrayBuffer()
if (webp && contentType.includes("image/webp")) {
// already webp
webp = false
}
if (webp || width) {
let image = new fly.Image(data)
if (width) {
image.withoutEnlargement().resize(width)
}
if (webp) {
image.webp()
}
const result = await image.toBuffer()
data = result.data
}

If we got to this point in our function, then our response is in fact an image. Hurray!

Define the variable data that is equal to the image's binary data (input and output are much faster using binary data).

If webp is true (meaning the request headers include image/webp and the contentType includes "webp", then this indicates that the browser accepts the webp format AND that the response object (the image) is already in the webp format.

If the image is already in the webp format, there is no need for us to convert it. So, we’ll set our wepb variable to false, indicating that we will not need to later convert it.

In this case, we will only do the next code block if we need to resize the image, which will ultimately make the image webp format AND correctly sized.

If the image does not need to be resized AND is already in the webp format, then we will not do the code block below and we will basically just throw this image in the cache (making for faster future downloads) and return it to the user.

if (webp || width) If the image is NOT already in the webp format OR it needs to be resized, keep on going.

Create the image variable, which is equal to a new fly.image instance using the image's binary data, equating to fast downloads and space savings!

If width is equal to a number and is not undefined, then we'll take our new image variable and resize it based on whatever number of pixels is in the width variable.

withoutEnlargement means do not enlarge the output image if the input image width or height are already less than the required dimensions.

Then, if webp is true (meaning the browser accepts webp), convert our image to the webp format.

Create a new variable result that is equal to the image converted to buffer (better for handling raw binary data than strings). Then set data equal to the image's buffer data.

resp = new Response(data, resp) 
if (webp) resp.headers.set("content-type", "image/webp")
resp.headers.set("content-length", data.byteLength)
await fly.cache.set(key, resp, 3600 * 24) // cache for 24h
resp.headers.set("Fly-Cache", "MISS") return new Response(data, resp)
}
}

Finally, create a new Response object with the image's data as the body.

if webp is true, change the image's content-type header to image/webp.

Set the content-length header of the image equal to it's byteLength. The byteLength property represents the length of an ArrayBuffer in bytes.

Cache the image at this key for 24hrs.

Make Fly-Cache "miss" because the response right now is not being served from the cache, its being served anew.

Finally, return the image in its new format (webp) to the user.

Here is a simple example invoking our function

// index.js 
import { processImages } from './images'
import proxy from '@fly/proxy'
const example = proxy("https://origin-www.example.com", { host: "www.example.com" })
const images = processImages(example)
fly.http.respondWith(function (req) {
const url = new URL(req.url)
if (url.pathname.match(/\.(png|jpg|jpeg)$/)) {
// this uses our processImages function to create webp versions
of images
return images(req)
} else {
// this just goes to the origin, no extra processing
return example(req)
}
})

We are using the @fly/proxy library to proxy to an origin … in this case, we're using example.com. You can read more about the proxy library and how it works here.

If our request’s url contains “png, jpg, or jpeg”, then we will convert the images to the webp format. If not, we will just return the request (no images to be converted).

WebP = fast downloads = better Lighthouse score = happy users!

Lighthouse results: before WebP conversion

This image shows the results of a Lighthouse report for a website before all of it’s images were converted to WebP using this library. Can you believe it almost took 40 seconds to download all of the site’s PNG/JPG images?! And that this accounts for over 7,000 KB of space?! Not to mention a performance score of 29, eeek.

Lighthouse results: after WebP conversion

Ahh, that’s more like it. A perfect next-gen image score and zero waiting time for image downloads! What will you do with all of that extra time? Learn to ski? Study French? Take a hike? The opportunities are endless…

Time to try it for yourself

Just pop the images.js library into your own Fly app and see for yourself how fast your images will load and how much higher your Lighthouse performance score will be.

As you can see, an overall performance score of 75 is better, but still not perfect. This is only part 1 of our Google Lighthouse series. There’s lots more you can do with Fly to better your Lighthouse scores… Stay tuned for more!

Originally published at fly.io on June 28, 2018.

--

--

Kurt Mackey
Fly.io
Editor for

I do random, sometimes useful things with computers (and fire).