Written by Rahul Padalkar✏️
NAPI-RS is a framework for building modules for Node.js using Rust, and we can leverage NAPI-RS for building modules that can perform tasks like image resizing, cryptographic operations, and more.
I’m going to show you how to build an image resizer in Rust using NAPI-RS and expose it to our Node.js application. Then we will compare the performance of our resizer that we will have written in Rust with NAPI-RS to sharp.
Set up a new NAPI-RS project
To start using NAPI-RS, le’ts install the CLI tool. We will use this tool to bootstrap the project. To install the CLI tool, run the command below:
# if you use npm
npm i -g @napi-rs/cli
# if you use yarn
yarn global add @napi-rs/cli
This will install the CLI tool globally. Now to start a new project, run this command:
napi new
You will be prompted for some inputs. It will ask for details like the package name, enabling GitHub actions, and the platforms you want to target.
Since we are building an image resizer, we will name the package image-resizer
, and we can skip GitHub actions. For target platforms, we can select all major operating systems like Linux, Windows, and macOS.
Remember — for NAPI-RS to create a project successfully, Rust needs to be installed on your machine.
Here are two important files in the project folder structure that we will edit in the next steps:
-
src/lib.rs
— This is the file where we will write our Rust code. We will define a function here later that will be used for resizing images -
./Cargo.toml
— This file gives more information about the Rust project. We will add a few crates, or Rust’s equivalent of npm packages, to help us resize images
With that out of the way, let’s start adding code to src/lib.rs
.
Building an image resizer in Rust using the image crate
We will use the image
crate in Rust to resize images. To install the crate, head to the ./Cargo.toml
file, and add the image crate in the dependencies section:
......
.......
[lib]
crate-type = ["cdylib"]
[dependencies]
# Default enable napi4 feature, see https://nodejs.org/api/n-api.html#node-api-version-matrix
napi = { version = "2.12.2", default-features = false, features = ["napi4"] }
napi-derive = "2.12.2"
image = "0.24.6" // add this line
[build-dependencies]
napi-build = "2.0.1"
........
.......
Now let’s add code to src/lib.rs
file:
use image::GenericImageView;
use std::fs;
use std::path::Path;
#[macro_use]
extern crate napi_derive;
#[napi]
pub fn resize_image(image_dir: String) {
for entry in fs::read_dir(image_dir).expect("Failed to read directory") {
let entry = entry.expect("Failed to get directory entry");
let path = entry.path();
process_image(&path)
}
}
pub fn process_image(path: &Path) {
let image_reader = image::io::Reader::open(path).expect("Failed to open image");
let image_format = image_reader
.format()
.expect("Failed to determine image format");
let img = image_reader.decode().expect("Failed to decode image");
let (width, height) = img.dimensions();
println!(
"Processing image: {:?} ({}x{})",
path.file_name().unwrap(),
width,
height
);
let resized_img = img.resize(800, 600, image::imageops::FilterType::Lanczos3);
let new_path = path.with_file_name(format!(
"{}_resized.{}",
path.file_stem().unwrap().to_string_lossy(),
path.extension().unwrap().to_string_lossy()
));
resized_img
.save_with_format(new_path, image_format)
.expect("Failed to save image");
}
There are quite a few things to unpack here. Let’s start from the top:
use image::GenericImageView;
use std::fs;
use std::path::Path;
We start with some import statements. We will use the imported code for accessing images in a folder and resizing them:
#[napi]
pub fn resize_image(image_dir: String) {
for entry in fs::read_dir(image_dir).expect("Failed to read directory") {
let entry = entry.expect("Failed to get directory entry");
let path = entry.path();
process_image(&path)
}
}
We define the resize_image
function here that takes one input, a path to an image directory (string). The code in this function iterates over all the images in the image directory and passes them one by one to the process_image
function.
We have added #[napi]
macro at the top of the function to make it callable in JavaScript:
pub fn process_image(path: &Path) {
let image_reader = image::io::Reader::open(path).expect("Failed to open image");
let image_format = image_reader
.format()
.expect("Failed to determine image format");
let img = image_reader.decode().expect("Failed to decode image");
let (width, height) = img.dimensions();
println!(
"Processing image: {:?} ({}x{})",
path.file_name().unwrap(),
width,
height
);
let resized_img = img.resize(1280, 720, image::imageops::FilterType::Lanczos3);
let new_path = path.with_file_name(format!(
"{}_resized.{}",
path.file_stem().unwrap().to_string_lossy(),
path.extension().unwrap().to_string_lossy()
));
resized_img
.save_with_format(new_path, image_format)
.expect("Failed to save image");
}
This function takes in a path to an image. The image is opened using the Reader
from the image
crate, and it returns a Reader
upon successfully opening the file. The code throws an error if it fails to open the image.
We determine the image format and then find the image dimensions. We then read the image data using the decode
method on the Reader
. To resize the image, we call the resize
method on the output of the decode
method.
We pass in the width
and the height
, and we pass the filter we want to use to resize the image. We then create a new path for the new image and save the image there.
Now that we know what the code does, let’s compile the Rust code to a Node.js module.
Building the Node.js module
For building the Rust code into a consumable Node.js module, we run the following:
# for npm
npm run build
# for yarn
yarn build
This will take a while when building for the first time. Once this runs successfully, we can see a few files being generated.
The command will generate an index.js
along with type definitions generated in index.d.ts
. You will also see a .node
add-on generated. This is a Node.js add-on binary file. This Node add-on will be referenced in the index.js
file.
Using the compiled code to resize images
Now that we have everything, let’s try to call the resize_images
function in Node.js. For this, let’s first create a resizer.js
file at the root of our project.
Then add the code below:
const { resizeImages } = require("./index.js");
function resize() {
const images = "./images-100";
resizeImages(images);
}
resize();
This code will import the resizeImages
function, the one that we exposed from Rust. We call the function by passing to it the path of the folder that has images.
Performance comparison with sharp
Now let’s compare the performance of our image resizer with the one available in the sharp
npm package.
Let’s first create a node project to install the sharp library and then write code for our image resizer:
mkdir sharp-image-resizer
cd sharp-image-resizer
npm init
npm i sharp
Now let’s create a resizer.js
file and add the code below:
const sharp = require("sharp");
const fs = require("fs");
const path = require("path");
const imageDir = "./images-100";
const resizeImage = async (input, output) => {
try {
const inputPath = path.join(imageDir, input);
const outputPath = path.join(imageDir, output);
await sharp(inputPath)
.resize(1280, 720, {
fit: sharp.fit.cover, // Ensure the image fills the 1280x720 box
})
.toFile(outputPath);
console.log(`Resized image saved to: ${outputPath}`);
} catch (error) {
console.error(`Error resizing image: ${inputPath}`, error);
}
};
// Process all images in the input directory
const processImages = () => {
fs.readdir(imageDir, (err, files) => {
files.forEach((file) => {
const [name, ext] = file.split(".");
const outputName = `${name}_resized.${ext}`;
resizeImage(file, outputName);
});
});
};
// Start processing
processImages();
The code above is pretty straightforward as we define two functions:
-
processImage
— This function reads all the files in the directory and splits the name and the extension of the file. The function creates a new name for the output file and passes the new name and file to theresizeImage
function -
resizeImage
— This function uses the sharp library to resize the image to a 1280 by 720 image and then saves it in the same directory with the new name
We will test both the resizers we built with 100 images, 1,000 images, and 10,000 images. For downloading images, we will use Lorem Picsum. We will write a small shell script that will call the Picusm API multiple times to download the image.
So, create a download_image.sh
file, and modify it to have the content below:
#!/bin/bash
# Directory to save downloaded images
output_dir="./images-10000"
# Number of images to download
num_images=10000
# Create output directory if it doesn't exist
mkdir -p "$output_dir"
# Loop to download images
for i in $(seq 1 $num_images); do
# Download 1920x1080 image and save it with a unique name
wget "https://picsum.photos/1920/1080" -O "$output_dir/image_$i.jpg"
echo "Downloaded image_$i.jpg"
done
echo "Downloaded $num_images images to $output_dir"
We can modify the num_images
variable to download the desired number of images from the Picsum service.
Below is the comparison in terms of milliseconds between sharp and our resizer:
Number of Images | NAPI-RS | sharp |
100 | 12315.7328 | 5840.7068 |
1000 | 123615.5546 | 57942.932 |
10000 | 707559.3812 | 551380.5028 |
As you can see, sharp is also double the speed compared to our resizer.
To make things a bit more interesting, we will use a rayon
crate from Rust that allows us to run code in a parallel fashion. To do that, we will edit the Cargo.toml
file:
......
.......
[lib]
crate-type = ["cdylib"]
[dependencies]
# Default enable napi4 feature, see https://nodejs.org/api/n-api.html#node-api-version-matrix
napi = { version = "2.12.2", default-features = false, features = ["napi4"] }
napi-derive = "2.12.2"
image = "0.24.6"
rayon = "1.7" // add this line
[build-dependencies]
napi-build = "2.0.1"
........
.......
We will also update src/lib.rs
file:
#![deny(clippy::all)]
use image::{DynamicImage, GenericImageView, ImageFormat};
use rayon::prelude::*;
use std::fs;
use std::path::Path;
#[macro_use]
extern crate napi_derive;
#[napi]
pub fn resize_images(image_dir: String) {
// Collect all image paths
let image_paths: Vec<_> = fs::read_dir(image_dir)
.expect("Failed to read directory")
.filter_map(Result::ok)
.map(|entry| entry.path())
.collect();
// Process images in parallel
image_paths.par_iter().for_each(|path| {
process_image(path);
});
}
fn process_image(path: &Path) {
// Load the image along with its format
let image_reader = image::io::Reader::open(path).expect("Failed to open image");
let image_format = image_reader
.format()
.expect("Failed to determine image format");
let img = image_reader.decode().expect("Failed to decode image");
// Resize the image
let resized_img = img.resize(1280, 720, image::imageops::FilterType::Lanczos3);
// Save the resized image in the original format
let output_path = path.with_file_name(format!(
"{}_resized.{}",
path.file_stem().unwrap().to_string_lossy(),
path.extension().unwrap().to_string_lossy()
));
resized_img
.save_with_format(output_path, image_format)
.expect("Failed to save image");
println!("Processed {:?}", path);
}
Here we change the code flow a bit. We collect all image paths, iterate over them in parallel, and pass them to the process_image
function.
Line numbers 11 to 15 collect all the paths in a vector, and then we use the par_iter
function from rayon
to iterate over the paths and pass them to the process_image
function.
Now let’s try to compare the performance of all three resizers we have built so far:
Number of images | NAPI-RS w/o Rayon (in ms) | sharp (in ms) | NAPI-RS with Rayon (in ms) |
100 | 12315.7328 | 5840.7068 | 2821.2008 |
1000 | 123615.5546 | 57942.932 | 24518.4134 |
10000 | 707559.3812 | 551380.5028 | 266075.6984 |
We have significantly reduced the time by adding Rayon. With rayon, our resizer is almost double the speed of sharp and at least four times faster than the previous version.
There are two important things to note:
-
Performance.now()
was used to calculate the time required for resizing the images in all three resizers - All three resizers were run on a 2019 MacBook Pro, 16GB RAM, Core i7 processor
Conclusion
In this post, we looked at NAPI-RS and how to create Node.js add-ons using Rust. We created a image resizer add-on and compared it against sharp and its associated results. We then optimized our image resizer to utilize CPU power fully, and we saw drastic changes in the results. NAPI-RS is a really powerful tool to build fast and efficient Node.js add-ons.
Thanks for reading!
200’s only ✔️ Monitor failed and slow network requests in production
Deploying a Node-based web app or website is the easy part. Making sure your Node instance continues to serve resources to your app is where things get tougher. If you’re interested in ensuring requests to the backend or third party services are successful, try LogRocket.
LogRocket is like a DVR for web apps, recording literally everything that happens on your site. Instead of guessing why problems happen, you can aggregate and report on problematic network requests to quickly understand the root cause.
LogRocket instruments your app to record baseline performance timings such as page load time, time to first byte, slow network requests, and also logs Redux, NgRx, and Vuex actions/state. Start monitoring for free.
Top comments (1)
I'm really fascinated by the interface between Rust and other languages at the moment, so I enjoyed reading how you used
#[napi]
here. Thanks for sharing!