Optimizing Images for Web Performance with NGINX

toddhgardner

Todd H. Gardner

Posted on June 14, 2022

Optimizing Images for Web Performance with NGINX

Images are a constant source of pain when developing websites. There are many formats and resolutions a developer must consider in order to maximize web performance. You’ll often end up with a cartesian explosion of the same image in different sizes and formats to support different scenarios.

For example, you don’t want to send a high res image meant for high DPI screens to a low DPI screen - you’d be wasting bandwidth and burning time. Using the right file format is equally important. WebP is usually the best choice since it keeps size down, but not all browsers support it.

Automating the Pain Away

Creating all the permutations of every image you need on your site can be time consuming and irritating. At Request Metrics we have a Photoshop template that will do some of the work for us on export, but it’s still a manual step.

What if, instead, we could let NGINX do the heavy lifting for us, and create the necessary images on the fly?

Disclaimer: What follows is more of a proof of concept than a best practice. Like they say in Jurassic Park:

Your scientists were so preoccupied with whether or not they could, they didn’t stop to think if they should.

One Image to Rule Them All

Here’s a big hero image from our web performance guide:

A big ol header image for retina screens

This image is 2000px wide and meant to be used for high DPI screens, like those found on Apple’s retina displays. We often work in PNG format because it’s widely supported by creative tools and browsers.

The problem is this image is huge - both in terms of resolution and file size - weighing in at 350kb.

We’d like to be able to use the srcset property to specify 1x and 2x WebP images if the browser supports it, or fallback to PNG if not:

<!-- If the browser supports webp, let's use that -->
<!-- NOTE: we also want high and low res versions! -->
<img srcset="/images/header.1x.webp 1x,
             /images/header.webp 2x"
        src="/images/header.webp" />

<!-- If the browser does *not** support webp, fallback to PNG -->
<img srcset="/images/header.1x.png 1x,
             /images/header.png 2x"
        src="/images/header.png" />
Enter fullscreen mode Exit fullscreen mode

Essentially, we need four versions of the same image: high res and low res versions of the image in both PNG and Webp format. But we only have a single high res PNG and it’s a lot of irritation to make the other three versions for every image we want on the site. Isn’t this what computers are for - to do the boring work for us?

NGINX Image Module to the Rescue?

NGINX has long had an image module which can be used to resize images on the fly. You can even create endpoints that take custom width and height values.

This is cool, but we don’t want to think about image sizes. We just want a 1x that is half the resolution of the original, whatever that size might be. The NGINX image module also does not support changing file formats. So it doesn’t get us any closer to our WebP format goal either!

ImageMagick can do Everything

What we really need is something like ImageMagick. It’s a library that can read image attributes like width and height, do resizing, and even change format. A veritable swiss army knife for images. But how can we access it from within NGINX? Well, with a bit of trickery.

Lua Bindings for ImageMagick

There’s a large number of binding libraries out there to support ImageMagick from almost any programming language. Luckily there is someone who built one for Lua. Ermm, why do we care about Lua and how does this get us closer to our goal?

The NGINX Lua Module

NGINX has supported Lua as a scripting language for some time. It’s not usually enabled out of the box, but it’s easy enough to add with a simple module install. And once we have Lua support, we can use that fancy Lua-to-ImageMagick binding we found! Or at least, that’s the theory.

In Ubuntu-land, to enable Lua support for NGINX, use this:

 # Note: Other package managers have different names for the Lua module
apt-get install libnginx-mod-http-lua
Enter fullscreen mode Exit fullscreen mode

Talking to ImageMagick from NGINX

We’ve got Lua support enabled in NGINX, but we’re not quite done. If we look at the installation instructions for the ImageMagick Lua binding, we need a few more dependencies.

apt-get install luajit #<-- should be installed with Lua module
apt-get install libmagickwand-dev #<-- static libs for ImageMagick API
apt-get install luarocks #<-- Lua package manager

luarocks install magick #<-- Install the actual lua binding
Enter fullscreen mode Exit fullscreen mode

So now that we’ve got all the dependencies, we should be able to talk to ImageMagick directly from NGINX. Let’s see what sort of bad ideas we can come up with!

Converting PNG to WebP Automatically

For all of our images, we’d like the ability to convert them from PNG to WebP automatically. We can create a special location in NGINX to do exactly that for us!

The main thrust is that we’ll set up a special location in NGINX that handles all requests for .webp images. If the image exists on disk, great, we’ll just return it using the first argument of try_files. If it doesn’t, then we’ll look for a PNG of the same name and try and convert it to WebP on the fly!

# Handle any requests for .webp images.
# We first look for the file on disk, and if it's not present,
# call our internal location that does the conversion for us.
location ~* /images/(?<filename>.+).webp {
    try_files $uri @webp;
}

# Internal-only location that will convert an existing PNG to WebP
# And save that WebP image to disk for future requests
location @webp {
    content_by_lua_block {
        local magick = require("magick")
        local rootDir = '/var/www/images/'
        local srcImgPath = rootDir .. ngx.var.filename .. '.png'
        local img = magick.load_image(srcImgPath)
        if not img then
            ngx.status = 404
            ngx.exit(0)
        end

        img:set_format('webp')
        img:set_quality(100)
        local destFileName = ngx.var.filename .. '.webp'
        local destImgPath = rootDir .. destFileName
        img:write(destImgPath)

        ngx.exec("/images/" .. destFileName)
    }
}
Enter fullscreen mode Exit fullscreen mode

There’s a bit to unpack here. Let’s start with the top-level location.

location ~* /images/(?<filename>.+).webp {
    try_files $uri @webp;
}
Enter fullscreen mode Exit fullscreen mode

This location handles all requests to .webp images. It first looks for the file on disk, and if it doesn’t find it, it delegates to a fallback internal location called @webp.

location @webp {
    content_by_lua_block {
    ...
Enter fullscreen mode Exit fullscreen mode

You may then notice the content_by_lua_block directive. This lets us execute arbitrary Lua code right inside the NGINX conf. There are a few of these directives at various places in the NGINX request/response lifecycle, and there are versions that take a file on disk instead of inline code.

    local magick = require("magick")
    local rootDir = '/var/www/images/'
    local srcImgPath = rootDir .. ngx.var.filename .. '.png'
    local img = magick.load_image(srcImgPath)
    if not img then
        ngx.status = 404
        ngx.exit(0)
    end
Enter fullscreen mode Exit fullscreen mode

Next up we load up the ImageMagick binding that we installed and use it look for an existing PNG file on disk. If we don’t find an existing PNG file to convert, we use the intrinsic ngx object to create a 404 response.

    img:set_format('webp')
    img:set_quality(100)
    local destFileName = ngx.var.filename .. '.webp'
    local destImgPath = rootDir .. destFileName
    img:write(destImgPath)
Enter fullscreen mode Exit fullscreen mode

If we have an image, we set the format to webp and quality to 100 and re-save it with the .webp file extension. This effectively converts the PNG to WebP with lossless compression.

    ...
    ngx.exec("/images/" .. destFileName)
Enter fullscreen mode Exit fullscreen mode

Finally we use ngx.exec() to internally redirect to our newly saved WebP file! Subsequent requests will skip all this Lua code and just be served the file from disk.

Taking it Further

We can now convert PNG to WebP on the fly, and with good performance! But we also want to create lower res versions automatically when required. We can accomplish this with a few more lines of code in a new Lua block.

Making 1x Images

When we say 1x or 2x images we’re really talking about the devicePixelRatio. That is, the ratio of one CSS pixel to one physical pixel. So, let’s say our website is 1000px wide as defined in CSS. On a retina device with a 2x device pixel ratio, we need a 2000px wide image to fill that space without the browser upscaling it.

However, if the user isn’t using a high resolution display, it would be better to send them the “1x” version of the image - one that is 1000px wide. This will save bandwidth and time on the wire. Since we only want to deal with a single image, we only make a high resolution version, and we’ll let NGINX resize it to the lower resolution automatically.

Inside our site configuration we can do something like this:

# Look for any images with a {filename}.1x.{extension}
# We'll support PNG *or* WebP for fun
location ~* /images/(?<filename>.+).1x.(?<extension>png|webp) {
    try_files $uri @1x;
}

location @1x {
    content_by_lua_block {
        #... same directory setup and 404 returning code as before

        if ngx.var.extension == 'webp' then
            img:set_format('webp')
            img:set_quality(100)
        end

        local imgWidth = img:get_width()
        local imgHeight = img:get_height()
        local destFileName = ngx.var.filename .. '.1x.' .. ngx.var.extension
        local destImgPath = rootDir .. destFileName
        img:resize(imgWidth / 2, imgHeight / 2)
        img:write(destImgPath)

        ngx.exec("/images/" .. destFileName)
    }
}
Enter fullscreen mode Exit fullscreen mode

The main difference from the previous code is that we interrogate the image for it’s width and height, and then we divide those values by two to make a 1x image.

    local imgWidth = img:get_width()
    local imgHeight = img:get_height()
    img:resize(imgWidth / 2, imgHeight / 2)
Enter fullscreen mode Exit fullscreen mode

The Results

Our original PNG was 350kb. The resulting 1x PNG is a much smaller 204kb.

The WebP images are even more compact! The 2x image is only 110kb , and the 1x image is 108kb (not nearly as big of a savings due to the nature of WebP’s compression).

It’s not much work to configure your NGINX static content server to do image conversions for you. The Lua script is only executed once per image so there should not be undue pressure on the server itself. And getting 2-3x smaller payloads for “free” is something everyone can get excited about!

Does it Make a Difference?

The only way to find out is to use a product like Request Metrics to measure the real performance numbers for your site. Find out what actual users are experiencing and if your changes are making a difference!

💖 💪 🙅 🚩
toddhgardner
Todd H. Gardner

Posted on June 14, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related