optimize images in nodejs using sharp

#sharp#image-processing#optimization

by msx47

[edited ]

5 minute read
Photo by Kier In Sight on UnsplashPhoto by Kier In Sight on Unsplash

table of contents

introduction

When dealing with user-submitted images it is important to optimize them so that the appropriate image can be loaded on different device sizes. In this article, I demonstrate how to build a simple optimization pipeline with sharp.

why optimize

Optimization of images is important because more often than not the main culprits of a slow web app are poorly optimized images. Not only do improperly sized images contribute to most of the cumulative layout shift (CLS), they also are a bottleneck for loading time on slower connections.

about sharp

Sharp is an npm module that can be used for many image processing tasks. It is a low-level package, partially written in C++ on top of a library called libvips, this makes it extremely fast and efficient even when editing large images with multiple transformation steps.

setting up a project

in a new folder, initialize an npm project using

npm init -y

now install the sharp module with

npm i sharp

Create a new folder src and add index.js file inside it, then create a folder named images and another named output.

for ease of use you can add the following script to your package.json

"scripts": {
  "dev": "node src/index.js"
}

Now add the this 2.4MB image to the images folder.

now let us import the required modules and read the image file inside src/index.js

import sharp from "sharp";
import fs from "fs";
import path from "path";

const fileName = "kier-in-sight-ynBpPyCKRtw-unsplash.jpg";
const imageDir = path.join(process.cwd(), "images");
const outputDir = path.join(process.cwd(), "output");

// this is just for demonstration so I am using 
// synchrounous method. In production you should 
// almost always use async version i.e. readFile

const imageBuffer = fs.readFileSync(imageDir + fileName);

Sharp takes many types of inputs. It can read an image in form of a string, a buffer, an IntArray and a few other types.

specifying image sizes

The image sizes you generate will depend on what sizes you want to support or how precise you want to be with your sizes. I usually generate 4 standard sizes based on common CSS breakpoint lengths i.e. 320px, 768px, 1024px and 1280px. Of course, one can generate many more, this serves my case well enough and is a viable option for the free cloud storage I am using.

I usually add another dimension of 10px that produces pixelated image of a couple of hundred bytes. This helps with inlining a pixelated version of the image and lazy loading the original image. I will write a separate blog detailing how I use this approach for a smoother UX on the frontend but for now, we'll just add 10 to the list of widths.

add the following line to src/index.js

const imageWidths = [320, 768, 1024, 1280, 10];

aspect ratios

We have the image widths and can resize the image right away however sometimes you might want to use set a custom width and height ratio for the sake of consistency across images. For eg. a popular aspect ratio to use is 4:3. I use 16:9 on this blog. To force a 4:3 ratio, given the width, we can use some maths to get a height and pass that height to sharp.

// calculate height when width is given to get a ratio of 4:3
function calc4_3Height(width){
  return Math.floor((width * 3) / 4);
}

now an obvious question is that wouldn't this destroy the natural look of the image?

and the answer is yes, but we can use an option built into sharp called contain to preserve the natural aspect ratio while producing an image of our desired ratio.

from the sharp docs

contain: Preserving aspect ratio, contain within both provided dimensions using "letterboxing" where necessary.

Here letterboxing means padding the parts of the image which can't be occupied by the original image. The color of the padding can be specified and I mostly use transparent.

web friendly image formats

Choosing the correct file format is a crucial part of the optimization process. We ideally would like to have good compression along with the support of high color depth. I use webp for all the images on my blog because of its exceptional compression. You can choose from any of the common types of image formats, all with their tradeoffs.

modern formats like webp and avif, although great, are not supported by IE so choose wisely if you plan to support IE

finishing up the code

Keeping the above information in mind let us complete the resizing code

// get the fileName without the extension
let fileNameWithoutExt = fileName.split(".").slice(0, -1).join("");

const sharpImage = sharp(imageBuffer);

// the sharp library uses promises and thus we can loop over all the
// imageWidths and store the promise returned by sharp in each iteration
// in an array (await can also be used with a for loop inside an async function)
const promises = [];

imageWidths.forEach(width => {

  const height = calc4_3Height(width);
  
  // note that sharp has many more options available but I am using only
  // those which I need to get the job done
  const promise = 
       sharpImage
       //pass and width to the resize function
      .resize(width, height, {
      
        // contain for preserving the aspect ratio inside given width/height
        fit: "contain",
        
        // a transparent background using the alpha value of 0
        background: { r: 255, g: 255, b: 255, alpha: 0 },
      })
      .webp() // change this with your required file type
      
      // write the output of above transformations to a file
      // I use naming convention of *filename-widthxheight*
      .toFile(`${outputDir + fileNameWithoutExt}-${width}x${height}.webp`);
      
  // at last push the promise returned by sharp to our array    
  promises.push(promise);
  
});

// here we could either use an async IIFE or a top level await if
// you have that configured. I'll just use and IIFE

(async () => {
  await Promise.all(promises);
  console.log("done resizing");
})();

That is it. You now have a process of resizing user submitted images to any width you want.

extra credits

As you can see we are saving the files on the local disk which isn't always what you'd want. Ideally, we would upload the file onto cloud storage like S3. I use supabase storage for saving my image files because it's free and fairly straightforward to use. You can find the implementation of this blog's image upload here. It is a vercel serverless function running on the free tier and is super snappy even for large file sizes.

This was just part-1 of a 2 part series I have planned on image optimization. The next one would deal with the frontend and I'll explain how I used the low-res image we created here in nextjs.

thanks for reading :)