A quick experiment
I’ve been working with JavaScript as my daily job for a few years now. Lately, I started fiddling with Rust, to learn new concepts and understand what the hype is all about (see the Stack Overflow Survey 2023)
It looks like more and more JS tools are written in Rust lately:
Why would someone want to do this?
- JavaScript has wide adoption in the industry but performance is not great at all
- Rust has killer performance but has a harder learning curve
Putting both of them together is a way to get the best of both worlds-develop in an easy-to-learn and widespread language, JavaScript, and optimize critical parts with Rust.
So, how can Rust and JavaScript be brought together in a codebase?
Let’s have a look at napi-rs!
npx @napi-rs/cli new
The default project defines the following src/lib.rs file:
#![deny(clippy::all)]
#[macro_use] extern crate napi_derive;
#[napi] pub fn sum(a: i32, b: i32) -> i32 { a + b }
Running yarn build will create:
- A node addon binary file: <project name>.<target>.node
- An index.js that defines the JS bindings for the node addon binary: it loads the right binary and exports the sum function
- An index.d.ts that exposes type definitions for index.js
Using the sum function is straightforward:
const { sum } = require("./index.js"); console.log(sum(40, 2));
Serde is a Rust library that allows serializing to and deserializing from various data formats, including JSON.
Let’s compare how fast we can go with Serde + napi-rs compared to JS default JSON parsing.
For this test, we’ll parse objects with the following shape:
{
name: string
phoneNumbers: string[]
}
The Rust code for the Serde-based parse function looks like this:
#![deny(clippy::all)]
#[macro_use] extern crate napi_derive;
use serde::{Deserialize, Serialize}; #[napi(constructor)]
#[derive(Serialize, Deserialize)]
pub struct Person {
pub name: String,
pub phones: Vec<String>,
}
#[napi]
pub fn parse(data: String) -> napi::Result<Person> {
Ok(serde_json::from_str(&data)?)
}
Configuration
Some configuration has to be done in Cargo.toml on both serde and napi to make this code work:
Add serde-json to the list of features for napi:
napi = { …, features = [“napi4″,”serde-json”,] }
Add the derive feature to serde:
serde = { …, features = [“derive”] }
Error handling
Rust and JS have very different error-handling systems.
- Exceptions for JS
- The Result type for Rust
napi::Result<T> allows to turn a Rust Error into a JS exception automatically.
Running the parse function from a JS file with an invalid JSON throws an exception:
const index = require("./index.js");
const parsedValue = index.parse("Invalid JSON");
console.log(parsedValue);
const parsedValue = index.parse("Invalid JSON");
^
Error: expected value at line 1 column 1
at ... { code: 'InvalidArg' }
parse also throws an exception if the received data doesn’t have the right shape:
const index = require("./index.js");
const parsedValue = index.parse('{"name": "John Doe"}');
console.log(parsedValue);
const parsedValue = index.parse('{"name": "John Doe"}');
^
Error: missing field `phones` at line 1 column 20
at ... { code: 'InvalidArg' }
A quick micro-benchmark
Since Serde does parsing and validation, here is the function parse will be compared against:
const yup = require("yup");
const schema = yup.object({
name: yup.string(),
phones: yup.array(yup.string()),
});
const parseJs = (data) => {
const d = JSON.parse(data);
return schema.validateSync(d);
};
And the benchmark function:
const benchmark = (name, f, size) => {
console.time(name);
for (let i = 0; i < size; i++) {
f(`{ "name": "John Doe", "phones": [ "+33 123456789" ] }`);
}
console.timeEnd(name);
};
On 1M calls, the results are as follows:
JS: 8.356s
Rust: 2.966s
Running tests on 1000 iterations with objects of different sizes:
|`phones` array size|Rust (ms)|JS (ms)|
|-------------------|---------|-------|
| 1| 4,758| 51,946|
| 10| 9,334| 41,266|
| 100| 61,586|220,485|
| 1000| 499,942| 1987|
| 10000| 5338| 22685|
In this benchmark, the Rust parsing function seems to be 4x faster than the JS one.
Conclusion
napi-rs allows to easily use the power of Rust in a JavaScript codebase:
- Exporting functions from Rust doesn’t require much work and they can be imported in JavaScript seamlessly.
- It looks like there is a performance win in doing this (even though microbenchmarks should not be given too much credit)
- This setup requires a build step that makes using it less convenient than a JS-only setup
The code I used for this experiment can be found on this GitHub repository. Thanks for reading.
Originally published at thoughtful-fiddler.dev
Exploring JavaScript-Rust Interoperability With napi-rs was originally published in Better Programming on Medium, where people are continuing the conversation by highlighting and responding to this story.