Optimizing Images for Web Performance with NGINX
Tags: webperf, nginx, webdev, devops
Published: 2022-06-14 15:23:27

---
title: Optimizing Images for Web Performance with NGINX
published: true
date: 2022-06-14 08:00:00 UTC
tags: webperf, nginx, webdev, devops
canonical_url: https://requestmetrics.com/web-performance/nginx-image-optimization
cover_image: https://requestmetrics.com/assets/images/webperf/nginx-images/nginx-images-teaser-2000.min.png
---

Images are a constant source of pain when developing websites. There are [many formats](https://requestmetrics.com/web-performance/high-performance-images#1-image-format) and [resolutions](https://requestmetrics.com/web-performance/high-performance-images#2-responsive-image-resolution) 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.

<!--more-->

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](https://developers.google.com/speed/webp) is usually the best choice since it keeps size down, but [not all browsers](https://caniuse.com/webp) 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](https://requestmetrics.com/web-performance/measure-web-performance):

 ![A big ol header image for retina screens](https://requestmetrics.com/assets/images/webperf/nginx-images/header.png)

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:

```html
<!-- 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" />
```

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](http://nginx.org/en/docs/http/ngx_http_image_filter_module.html) which can be used to resize images on the fly. You can even create endpoints that take [custom width and height values](https://monicalent.com/blog/2019/01/06/responsive-images-with-nginx/).

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](https://imagemagick.org/index.php). 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](https://github.com/leafo/magick). 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:

```bash
 # Note: Other package managers have different names for the Lua module
apt-get install libnginx-mod-http-lua
```

### 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](https://github.com/leafo/magick#installation) for the ImageMagick Lua binding, we need a few more dependencies.

```bash
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
```

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!

```conf
# 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)
    }
}
```

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

```conf
location ~* /images/(?<filename>.+).webp {
    try_files $uri @webp;
}
```

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`.

```conf
location @webp {
    content_by_lua_block {
    ...
```

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](https://github.com/openresty/lua-nginx-module#directives) at various places in the NGINX request/response lifecycle, and there are versions that take a file on disk instead of inline code.

```lua
    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
```

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`](https://github.com/openresty/lua-nginx-module#introduction) object to create a 404 response.

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

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.

```lua
    ...
    ngx.exec("/images/" .. destFileName)
```

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`](https://developer.mozilla.org/en-US/docs/Web/API/Window/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:

```conf
# 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)
    }
}
```

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.

```conf
    local imgWidth = img:get_width()
    local imgHeight = img:get_height()
    img:resize(imgWidth / 2, imgHeight / 2)
```

## The Results

Our [original PNG](https://requestmetrics.com/assets/images/webperf/nginx-images/header.png) was **350kb**. The resulting [1x **PNG**](https://requestmetrics.com/assets/images/webperf/nginx-images/header.1x.png) is a much smaller **204kb**.

The WebP images are even more compact! The [2x image](https://requestmetrics.com/assets/images/webperf/nginx-images/header.webp) is only **110kb** , and the [1x image](https://requestmetrics.com/assets/images/webperf/nginx-images/header.1x.webp) 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](https://app.requestmetrics.com/signup) to measure the real performance numbers for your site. Find out what actual users are experiencing and if your changes are making a difference!