Thursday, 12 July, 2018 UTC


Summary

This article is part 3 of Fly's Google Lighthouse Series, which is a series aimed at helping you achieve better Lighthouse scores through solutions to each Lighthouse audit recommendation. The preceding articles in this series talk about what a Lighthouse score is, how to run an audit for your site, and how to serve images in next-gen formats + lazy loading offscreen images for better performance scores. They can be found here, enjoy!
Next up in our Google Lighthouse Series, we’re going to tackle a crucial website optimization feature that greatly impacts your site’s performance ... properly sizing images.
Let's say you just created a new landing page for your site with big, beautiful images that are just appropriately sized for your audience's large desktop screens. A user navigates to your site’s URL on their desktop and all images are downloaded in a timely manner, resulting in a happy user. Now let’s say the next user visits your site on their smartphone. Those big, beautiful, desktop-version images are now having a hard time configuring themselves to fit on that tiny device. It’s taking forever for these images to figure out how to downsize themselves properly and load to the screen. In the meantime, is your user even still there? Unfortunately, probably not. What if there was a simple way to tell our site to resize images on-the-fly and help us pass this section of the audit?
Oversized images = Overworked web pages
In the results of a Lighthouse report, you'll come across the "opportunities" section. The opportunities section is a great overview of what you can do to help speed up your site. Think of it as a super helpful to-do list. The items on this list should never be ignored, as they have a significant impact on your site's performance. You may see the recommendation to properly size images, along with a list of every image that failed. If you refactor how you serve these images to ensure that they're properly sized for different types of devices then you’ll likely pass this portion of the audit and improve your performance score.
Ideally, your page should never serve images that are larger than the user's screen resolution. Anything larger than a given viewport will slow down page load time, waste several bytes of bandwidth and negatively affect your site's performance. Good news is, the Fly team has come up with an easy solution to this common problem.
Show me the code
The example below demonstrates how you can use Fly to boost your Lighthouse performance score by properly sizing images, on-the-fly. It works by searching your page for any image tags and resizing those images based on a given width. Basically, you can easily specify different widths for different devices. And you can have peace of mind in knowing that appropriate sizes are being loaded for your users based on their device resolution.
// images.js
export function processImages(config, fetch) {
  if (!fetch) {
    fetch = config
    config = undefined
  }
  return async function processImages(req, opts) {
    const url = new URL(req.url)
    const sizes = (config && config.sizes) || {}
    let width = undefined

    if (url.search) {
      const key = url.search.substring(1)
      const s = sizes[key]
      if (s && s.width) {
        width = s.width
      }
    }

    let resp = await fetch(req, opts)
    let data = await resp.arrayBuffer()
    
      if (width) {
        let image = new fly.Image(data)
        image.withoutEnlargement().resize(width)
        const result = await image.toBuffer()
        data = result.data
      }

    resp = new Response(data, resp)
    resp.headers.set('content-length', data.byteLength)
    return new Response(data, resp)
  }
}

processImages.withConfig = function withConfig(config) {
  return processImages.bind(null, config)
}
// index.js
import proxy from '@fly/proxy'
import { processImages } from './images'

const origin = proxy("https://www.example.com")
const assets = proxy("http://www.example.com/images/", { stripPath: "/images/" })

fly.http.respondWith(
  pipeline(
    responsiveImages,
    processImages.withConfig({
      sizes: {
        index: { width: 600 }
      }
    }),
    rewriteLinks,
    mount({
         "/images/": assets,
         "/": origin
    })
  )
)

const rewrites = [
     [/(https?:)?\/\/images\.example\.com/g, "/images"],
     [/https:\\u002F\\u002Fimages.example.com\\u002F/g, "\\u002Fimages\\u002F"],
]

function rewriteLinks(fetch) {
  return async function rewriteHTML(req, init) {
    req.headers.delete("accept-encoding")
    const resp = await fetch(req, init)
    if (app.env === "development") {
      resp.headers.delete("strict-transport-security")
      resp.headers.delete("content-security-policy")
    }
    const contentType = resp.headers.get("content-type") || ""
    if (
      contentType.includes("/html") ||
      contentType.includes("application/javascript") ||
      contentType.includes("application/json") ||
      contentType.includes("text/")
    ) {
      let body = await resp.text()
      for (const r of rewrites) {
        body = body.replace(r[0], r[1])
      }
      resp.headers.delete("content-length")
      return new Response(body, resp)
    }
    return resp
  }
}

function responsiveImages(fetch) {
  return async function responsiveImages(req, init) {
    if (req.method != "GET") {
      return fetch(req, init)
    }

    let resp = await fetch(req, init)
    const contentType = resp.headers.get("content-type") || ""

    if (!contentType.includes("html") || resp.status != 200) {
      return resp
    }

    resp.headers.delete("content-length")
    let body = await resp.text()
    const doc = Document.parse(body)
    const replacements = []

    for (const img of doc.querySelectorAll("img")) {
      let src = img.getAttribute("src")
      if (src) {
        replacements.push([
          src.replace(/\//g, "\\u002F"),
          src.replace(/\//g, '\\u002F') + '?index'
        ])
        img.setAttribute('src', `${src}?index`)
      }
    }

    body = doc.documentElement.outerHTML

    for (const r of replacements) {
      body = body.replace(r[0], r[1])
    }
    
    resp = new Response(body, resp)
    return resp
  }
}

function mount(paths) {
  return function mount(req, init) {
    const url = new URL(req.url)
    for (const p of Object.getOwnPropertyNames(paths)) {
      if (url.pathname.startsWith(p)) {
        return paths[p](req, init)
      }
    }
    return new Response("no mount found", { status: 404 })
  }
}

function pipeline() {
  let fn = null
  for (let i = arguments.length - 1; i >= 0; i--) {
    if (fn) {
      fn = arguments[i](fn)
    } else {
      fn = arguments[i]
    }
  }
  return fn
}
How it works
Our images.js files contains the function processImages. We’ll be exporting this function for use in our index.js file.
Inside of this function, we’re first grabbing the window’s current URL and storing it inside of our url variable. Our sizes variable will be equal to the new size (in pixels) that we want our image(s) to be.
Next, we’ll be searching our url for any search params (because we’ll eventually be rewriting our image URLs to contain a query string with new image size information). If we find new sizing information within our query string, we'll set the value equal to our width. Ultimately, width will be equal to a number in pixels representing the new width of our image(s).
If we do in fact come across a new width that’s needed, we'll create a new fly.Image instance using the image's binary data, stored in our data variable.
Then resize the image to its new width. withoutEnlargement means do not enlarge the output image if the input image width or height is already less than the required dimensions.
Create the variable result that is equal to the image converted to buffer (better and faster for handling raw binary data than strings). Then set data equal to the image's buffer data.
Next, create a new Response object with the image's data as the body.
Set the content-length header of the image equal to it's byteLength. The byteLength property represents the length of an ArrayBuffer in bytes.
Then, return the newly resized image(s)!
Finally, set processImages.withConfig equal to our processImages function with configuration options (config will eventually be the width that we will pass into this function when we invoke it below).
Now let’s take a look at our index.js file to see exactly how we’re putting all of this logic to work.
First, let’s import proxy from @fly/proxy. We are using the @fly/proxy library to proxy to an origin (your site’s URL) so that we can easily grab all of the images and manipulate them. You can read more about the proxy library and how it works here. Also, let's import our processImages function that we just created in our images.js file.
Next, we’re going to proxy to an origin. Replace example.com with your own sites URL here: const origin = proxy("https://www.example.com"). Then set assets equal to the path to your images (you can declare multiple variables here if you have multiple asset paths).
Then, take a look at these steps: const rewrites, function rewriteLinks(fetch) and function responsiveImages(fetch). What we're basically doing here is searching our document for any image tags. Once we find those images, we'll rewrite their URL's to contain the search param index in it's query string. This way we can easily add the width param to our image links ... which we'll put to use in our processImages function. Make sure that you configure these steps appropriately to match your own site's assets.
Finally, our mount(paths) and pipeline() functions are being used in fly.http.respondWith to tie everything together and serve our newly sized images to the user. When we invoke pipeline(), we’re passing in a list of functions as arguments to perform one at a time. So ultimately, we’re rewriting all of our image links to contain the index param. Then were invoking our processImages.withConfig function with the new width of 600. This means that no image will exceed 600 pixels. In other words, this would be especially helpful for device viewport sizes equaling 600px.
Instead of hard coding this number, you could also dynamically set this number by grabbing the current window's width and manipulating that to make for super responsive images! (example: something like $(window).width()-200 could be passed into the function to make all images at least 200px less than the current window's width)
Last but not least, our mount function returns our site's origin and images path with all of our images properly sized!
Lighthouse score before properly sizing images
The image above represents a portion of a Lighthouse audit where a site is failing to properly size their images. As you can see, space and time are being carelessly wasted while this site works hard to attempt to properly match image size to screen size ... causing them to fail this part of the audit and produce a less than optimal performance score.
Lighthouse score after properly sizing images
After we implement the code above into this site, you’ll see that we are now passing this portion of the audit with a perfect score... meaning that all of the images are properly sized and time + space is being saved by the user that is downloading the images ... hooray!
Where to go from here?
Evidently, the sizing of our images makes a huge impact on how well our sites are performing in today's competitive online space. For that reason, it’s important to not serve images that are larger than the version that's rendered on the user's screen.
Use the code above to resize your images based on the current window's width or a fixed width. Take a look at your Lighthouse score before and after implementing these changes and prepare to be astonished by the difference it makes. And if you’re feeling particularly dazzled, share your results with us on twitter!
We really just hit the tip of the iceberg with what you can do with Fly to boost your Lighthouse score ... stay tuned for more!