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.
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 imagesWith that out of the way, let’s start adding code to src/lib.rs
.
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.
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.
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.
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 the resizeImage
functionresizeImage
— 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 nameWe 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 resizersIn 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!
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 and mobile apps, recording literally everything that happens while a user interacts with your app. 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.
Debugging Rust applications can be difficult, especially when users experience issues that are hard to reproduce. If you’re interested in monitoring and tracking the performance of your Rust apps, automatically surfacing errors, and tracking slow network requests and load time, try LogRocket.
LogRocket is like a DVR for web and mobile apps, recording literally everything that happens on your Rust application. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app’s performance, reporting metrics like client CPU load, client memory usage, and more.
Modernize how you debug your Rust apps — start monitoring for free.
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowLearn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.
Bypass anti-bot measures in Node.js with curl-impersonate. Learn how it mimics browsers to overcome bot detection for web scraping.
Handle frontend data discrepancies with eventual consistency using WebSockets, Docker Compose, and practical code examples.
Efficient initializing is crucial to smooth-running websites. One way to optimize that process is through lazy initialization in Rust 1.80.