Exploring Javascript-Rust interoperability with napi-rs
()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:
- SWC, a Typescript compiler
- Prisma, a Typescript ORM with a database engine written in Rust
- Deno, a Javascript runtime
- And more
Why would someone want to do this?
- Javascript has a 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 word: 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!
Starting a new project with 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 thesum
function - An
index.d.ts
that exposes type definitions forindex.js
Using the sum
function is straight-forward:
const { sum } = require("./index.js");
console.log(sum(40, 2));
JSON.parse
vs Serde
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)?)
}
Some configuration has to be done in
Cargo.toml
on bothserde
andnapi
to make this code work:
- Add
serde-json
to the list of features fornapi
:
napi = { ..., features = ["napi4","serde-json",] }
- Add the
derive
feature toserde
:
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 on this experiment
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