Skip to content
rusthacks.dev
Systems

Node Addons in Rust With Napi rs for JavaScript Speed

napi-rs generates a Node-API addon from annotated Rust. Functions become JavaScript functions, async work runs on a thread pool, and TypeScript types come for free.

19 min read
Summarize in AI
Flat illustration for the post “Node Addons in Rust With Napi rs for JavaScript Speed”, composed around perf.

Node.js is excellent at I/O and a poor place to run a CPU-heavy loop: one thread executes your JavaScript, so a 250 ms computation freezes every request on the process for 250 ms. A native addon written in Rust fixes the freezing, not necessarily the speed, and it is worth being clear about that difference before writing any code. With napi-rs you write ordinary Rust functions, annotate them with #[napi], and get a .node binary that require() loads, with TypeScript declarations generated for you. This guide builds an addon from an empty directory with a synchronous function, a function returning structured data, a function that runs on Node's thread pool and returns a promise, an async fn, and a callback. It measures what the addon is good for (keeping the event loop free, and using several cores) and what it is not (beating V8 at string work), and it records two conversion traps that silently produce wrong answers. Everything ran on Node 24.1.0 with Rust 1.99, napi 3.16, napi-derive 3.7, napi-build 2.6 and the @napi-rs/cli 3.x tool, on an Apple M1 Max.

What N-API is, and why napi-rs

Node loads native code through N-API, a stable C interface for addons: functions to read arguments, create strings and objects, throw errors and schedule work, with a promise that binaries compiled against one version keep working on later Node versions. Writing against it by hand in C is tedious and unsafe. napi-rs wraps the interface: the #[napi] macro generates the glue that converts JavaScript values to Rust types and back, the napi-build crate configures the linker, and the @napi-rs/cli npm package drives the build, names the output file for the platform and generates the TypeScript declarations and the loader script. Because the interface is stable, you build one binary per operating system and CPU, not one per Node version.

The project

Cargo.tomlTOML
[package]
name = "fastnode"
version = "0.1.0"
edition = "2024"

[lib]
crate-type = ["cdylib"]

[dependencies]
napi = { version = "3", default-features = false, features = ["napi6", "async", "tokio_rt"] }
napi-derive = "3"
tokio = { version = "1", features = ["time"] }

[build-dependencies]
napi-build = "2"

[profile.release]
lto = true
build.rsrust
fn main() {
    napi_build::setup();
}
package.jsonJSON
{
  "name": "fastnode",
  "version": "0.1.0",
  "main": "index.js",
  "types": "index.d.ts",
  "napi": {
    "binaryName": "fastnode",
    "targets": ["aarch64-apple-darwin"]
  },
  "scripts": {
    "build": "napi build --platform --release"
  },
  "devDependencies": {
    "@napi-rs/cli": "^3.0.0"
  }
}

The crate type cdylib makes a dynamic library, which the CLI renames to fastnode.darwin-arm64.node, because Node loads files with the .node extension. The napi6 feature selects the N-API version you require (version 6 is old enough to be available on every supported Node), async enables the thread-pool task API and tokio_rt lets async fn exports run on a Tokio runtime that napi-rs manages; a direct tokio dependency with the time feature is still needed because your own code names tokio::time::sleep. default-features = false keeps the binary small. The build.rs is two lines and mandatory: napi_build::setup() passes the platform-specific linker flags (on macOS, the undefined-symbol flags that let the addon resolve N-API functions from the Node process that loads it). In package.json, napi.binaryName sets the output name and targets lists the platforms the project builds for, and the one script, napi build --platform --release, is the whole build. Run npm install once to fetch the CLI.

The Rust side

src/lib.rsrust
use napi::bindgen_prelude::*;
use napi_derive::napi;
use std::collections::HashMap;

/// Iterative Fibonacci, wrapping like a u64.
#[napi]
pub fn fib(n: u32) -> BigInt {
    let (mut a, mut b) = (0u64, 1u64);
    for _ in 0..n {
        (a, b) = (b, a.wrapping_add(b));
    }
    BigInt::from(a)
}

#[napi(object)]
pub struct WordCount {
    pub word: String,
    pub count: u32,
}

#[napi]
pub fn top_words(text: String, n: u32) -> Result<Vec<WordCount>> {
    if n == 0 {
        return Err(Error::new(Status::InvalidArg, "n must be at least 1"));
    }
    let mut counts: HashMap<String, u32> = HashMap::new();
    for w in text.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()) {
        *counts.entry(w.to_lowercase()).or_default() += 1;
    }
    let mut pairs: Vec<_> = counts.into_iter().collect();
    pairs.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
    pairs.truncate(n as usize);
    Ok(pairs.into_iter().map(|(word, count)| WordCount { word, count }).collect())
}

pub struct Churn {
    values: Vec<u32>,
    rounds: u32,
}

#[napi]
impl Task for Churn {
    type Output = u32;
    type JsValue = u32;

    fn compute(&mut self) -> Result<Self::Output> {
        Ok(self.values.iter().fold(0u32, |acc, v| {
            let mut h = *v ^ 0x811c9dc5;
            for _ in 0..self.rounds {
                h = (h ^ (h >> 15)).wrapping_mul(0x01000193);
            }
            acc ^ h
        }))
    }

    fn resolve(&mut self, _env: Env, output: Self::Output) -> Result<Self::JsValue> {
        Ok(output)
    }
}

#[napi(ts_return_type = "Promise<number>")]
pub fn churn(values: Vec<u32>, rounds: u32) -> AsyncTask<Churn> {
    AsyncTask::new(Churn { values, rounds })
}

#[napi]
pub async fn delayed_double(n: u32, ms: u32) -> Result<u32> {
    tokio::time::sleep(std::time::Duration::from_millis(u64::from(ms))).await;
    n.checked_mul(2).ok_or_else(|| Error::new(Status::GenericFailure, format!("{n} * 2 overflows u32")))
}

#[napi]
pub fn for_each_line(text: String, callback: Function<'_, FnArgs<(u32, String)>, ()>) -> Result<u32> {
    let mut n = 0;
    for (i, line) in text.lines().enumerate() {
        callback.call(FnArgs::from((i as u32, line.to_string())))?;
        n += 1;
    }
    Ok(n)
}

Each export shows a different idea. fib returns BigInt because a JavaScript number cannot hold every 64-bit integer: above 2 to the power of 53 it loses precision silently, and BigInt is the faithful type. #[napi(object)] on a struct makes a plain JavaScript object with typed fields, which top_words returns as an array. Returning Result<T> turns an Err into a thrown exception whose code is the Status you chose. The Churn struct implements Task: compute runs on a worker thread from libuv's pool (never on the JavaScript thread), and resolve runs back on the main thread to turn the result into a JavaScript value; wrapping it in AsyncTask makes the exported function return a promise. delayed_double is an ordinary async fn, run on the managed Tokio runtime. for_each_line receives a JavaScript function and calls it synchronously for each line.

Build and look at what was generated

Shell
npm run build

The release build took 12 seconds the first time and produced a 786 KB fastnode.darwin-arm64.node (the size includes the Tokio pieces), plus two generated files you commit: index.js, an 840-line loader that picks the right binary for the current platform and falls back with a clear error, and index.d.ts, the declarations. The declarations are the payoff for the annotations; this is the generated content for the exports:

index.d.tsTypeScript
export declare function churn(values: Array<number>, rounds: number): Promise<number>

export declare function delayedDouble(n: number, ms: number): Promise<number>

/** Iterative Fibonacci, wrapping like a u64. */
export declare function fib(n: number): bigint

export declare function forEachLine(text: string, callback: (arg0: number, arg1: string) => void): number

export declare function topWords(text: string, n: number): Array<WordCount>

export interface WordCount {
  word: string
  count: number
}

Rust names are converted to camelCase (top_words became topWords), doc comments become JSDoc, the struct became an interface, and the tuple callback parameters became two positional arguments. That last one deserves a note: the first version of for_each_line declared its callback as Function<'_, (u32, String), ()>, and the generated type was (arg: [number, string]) => void. The callback was called with one array argument, so a JavaScript function written as (i, line) => ... received the array in i and undefined in line, and the output read 0,alpha:undefined. Wrapping the argument type in FnArgs<(u32, String)>, as the final source does, spreads the tuple into positional arguments. Read the generated .d.ts after every signature change; it is the contract JavaScript sees.

Call it from JavaScript

demo.jsJavaScript
const { fib, topWords, churn } = require('./index.js');

console.log(fib(50), typeof fib(50));
console.log(topWords('the cat and the hat and THE bat', 2));
try {
  topWords('x', 0);
} catch (e) {
  console.log(e.code, '-', e.message);
}
try {
  fib(-1);
} catch (e) {
  console.log(e.code, '-', e.message);
}
churn([1, 2, 3], 10).then((v) => console.log('churn resolved', v));
console.log('this line prints before the promise resolves');
Text
12586269025n bigint
[ { word: 'the', count: 3 }, { word: 'and', count: 2 } ]
InvalidArg - n must be at least 1
this line prints before the promise resolves
churn resolved 806291124

The results are what the signatures promised: fib(50) is a bigint, topWords returns real objects, a bad argument throws with the code and message from Rust, and the promise resolves after the synchronous line has already printed, proving that churn ran off the main thread. The remaining exports behave the same way:

async_demo.jsJavaScript
const { delayedDouble, forEachLine } = require('./index.js');

(async () => {
  const t = Date.now();
  const [a, b] = await Promise.all([delayedDouble(21, 100), delayedDouble(5, 100)]);
  console.log(a, b, 'in about', Math.round((Date.now() - t) / 100) * 100, 'ms');
  try {
    await delayedDouble(3_000_000_000, 1);
  } catch (e) {
    console.log(e.code, '-', e.message);
  }
  const seen = [];
  const count = forEachLine('alpha\nbeta\ngamma', (i, line) => seen.push(`${i}:${line}`));
  console.log(count, seen);
})();
Text
42 10 in about 100 ms
GenericFailure - 3000000000 * 2 overflows u32
3 [ '0:alpha', '1:beta', '2:gamma' ]

Two concurrent calls with a 100 ms Rust-side sleep completed in about 100 ms in total, not 200, because the async function is truly asynchronous. The overflow error arrived as a rejected promise with the code and message from Rust. And the callback saw positional arguments.

Two conversions that quietly give wrong answers

PyO3 raises OverflowError for a negative number passed to an unsigned parameter. N-API's number conversion does not. JavaScript has one numeric type, a double, and napi-rs converts it to u32 by the engine's rules, which wrap:

probe.jsJavaScript
const { fib } = require('./index.js');
let t = process.hrtime.bigint();
const r = fib(-1);
console.log('fib(-1) =', r, 'in', Number(process.hrtime.bigint() - t) / 1e6, 'ms');
try { fib(1.5); console.log('fib(1.5) ok'); } catch (e) { console.log('fib(1.5):', e.code, e.message); }
try { fib('7'); console.log('fib("7") ok'); } catch (e) { console.log('fib("7"):', e.code, e.message); }
try { fib(2 ** 40); console.log('fib(2**40) ok'); } catch (e) { console.log('fib(2**40):', e.code, e.message); }
Text
fib(-1) = 6337557995341940450n in 1387.272208 ms
fib(1.5) ok
fib("7"): NumberExpected Failed to convert napi value String into rust type `u32`
fib(2**40) ok

fib(-1) did not throw. The value became 4,294,967,295, and the function ran four billion iterations for 1.39 seconds, blocking the event loop for the whole time, then returned a plausible-looking number. fib(1.5) truncated to 1, fib(2**40) wrapped to 0, and only a string was rejected. If a function takes an unsigned or bounded integer from untrusted JavaScript, accept i64 or f64 and validate the range yourself (if !(0..=1000).contains(&n) { return Err(...) }), or use BigInt for exact 64-bit values. A request handler that passes req.query.n to a Rust function would otherwise hand any client a denial-of-service switch.

What it is good for, measured

bench.jsJavaScript
const { topWords, churn } = require('./index.js');

function jsTopWords(text, n) {
  const counts = new Map();
  for (const w of text.split(/[^\p{L}\p{N}]+/u)) {
    if (!w) continue;
    const k = w.toLowerCase();
    counts.set(k, (counts.get(k) || 0) + 1);
  }
  return [...counts.entries()]
    .sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1))
    .slice(0, n)
    .map(([word, count]) => ({ word, count }));
}

function jsChurn(values, rounds) {
  let acc = 0;
  for (const v of values) {
    let h = (v ^ 0x811c9dc5) >>> 0;
    for (let i = 0; i < rounds; i++) {
      h = Math.imul((h ^ (h >>> 15)) >>> 0, 0x01000193) >>> 0;
    }
    acc = (acc ^ h) >>> 0;
  }
  return acc;
}

function best(f, runs = 5) {
  let b = Infinity;
  for (let i = 0; i < runs; i++) {
    const t = process.hrtime.bigint();
    f();
    b = Math.min(b, Number(process.hrtime.bigint() - t) / 1e6);
  }
  return b;
}

async function main() {
  const text = 'the quick brown fox jumps over the lazy dog and the cat '.repeat(20000);
  const js = best(() => jsTopWords(text, 3));
  const rs = best(() => topWords(text, 3));
  console.log(`topWords on ${(text.length / 1e6).toFixed(1)} MB: js ${js.toFixed(1)} ms | rust ${rs.toFixed(1)} ms | ${(js / rs).toFixed(1)}x`);
  console.log(JSON.stringify(jsTopWords(text, 3)) === JSON.stringify(topWords(text, 3)) ? 'same answer' : 'DIFFERENT');

  const values = Array.from({ length: 2000 }, (_, i) => i);
  const rounds = 20000;
  if (jsChurn(values, rounds) !== (await churn(values, rounds))) throw new Error('churn mismatch');

  let t = process.hrtime.bigint();
  for (let i = 0; i < 4; i++) jsChurn(values, rounds);
  const jsSeq = Number(process.hrtime.bigint() - t) / 1e6;

  t = process.hrtime.bigint();
  await churn(values, rounds);
  const rsOne = Number(process.hrtime.bigint() - t) / 1e6;

  t = process.hrtime.bigint();
  await Promise.all([1, 2, 3, 4].map(() => churn(values, rounds)));
  const rsFour = Number(process.hrtime.bigint() - t) / 1e6;
  console.log(`churn: js x4 on the main thread ${jsSeq.toFixed(0)} ms | rust x1 ${rsOne.toFixed(0)} ms | rust x4 in parallel ${rsFour.toFixed(0)} ms`);

  // event loop lag while the work runs
  async function lag(work) {
    let worst = 0, last = performance.now();
    const timer = setInterval(() => {
      const now = performance.now();
      worst = Math.max(worst, now - last - 10);
      last = now;
    }, 10);
    await new Promise((r) => setTimeout(r, 30));
    await work();
    await new Promise((r) => setTimeout(r, 30));
    clearInterval(timer);
    return worst;
  }
  const lagJs = await lag(async () => { for (let i = 0; i < 4; i++) jsChurn(values, rounds); });
  const lagRs = await lag(() => Promise.all([1, 2, 3, 4].map(() => churn(values, rounds))));
  console.log(`worst timer delay while working: js ${lagJs.toFixed(0)} ms | rust AsyncTask ${lagRs.toFixed(0)} ms`);
}
main();
Text
topWords on 1.1 MB: js 17.5 ms | rust 11.9 ms | 1.5x
same answer
churn: js x4 on the main thread 258 ms | rust x1 64 ms | rust x4 in parallel 66 ms
worst timer delay while working: js 258 ms | rust AsyncTask 2 ms

Read it honestly. Raw speed on string work is modest: counting words in 1.1 MB took 17.5 ms in V8 and 11.9 ms in Rust, a 1.5x difference. V8's string and map code is very good, and it takes a lot of Rust to beat. A tight numeric loop is a tie: the JavaScript version of the hash loop ran at the same speed as the Rust one (about 64 ms per pass), because V8 compiles integer loops with Math.imul to near-native code. If a profile says your slow function is a loop like this, an addon will not make a single call faster. What the addon changes is where the work runs. Four passes in JavaScript took 258 ms on the main thread, during which a 10 ms timer was delayed by up to 258 ms, which is a stalled server. The four Rust tasks ran on the thread pool in parallel and finished in 66 ms, and the worst timer delay was 2 ms: the event loop stayed responsive and the work used four cores. For comparison, Node's own answer to the same problem is worker_threads, which can run the same JavaScript in parallel without any Rust; the addon wins when the code is faster in Rust, when you want to share memory without copying, or when you need a Rust library that has no JavaScript equivalent.

Binary data, classes and calls from other threads

Three more exports cover the rest of what real addons need. Buffers: a Uint8Array parameter is a view onto the memory of a Node Buffer, not a copy, so passing a 100 MB buffer costs nothing and the function reads it in place. Classes: #[napi] on a struct and its impl produces a JavaScript class whose instances own a Rust value; #[napi(constructor)] is the constructor (an Option<u32> parameter makes an optional argument) and #[napi(getter)] makes a property. Threadsafe functions: JavaScript functions may only be called on the main thread, so a Rust thread that wants to report progress wraps the callback in a ThreadsafeFunction, which queues the call and lets Node run it on the event loop.

src/lib.rsrust
#[napi]
pub fn checksum(data: Uint8Array) -> BigInt {
    let h = data.iter().fold(0xcbf29ce484222325u64, |h, b| (h ^ u64::from(*b)).wrapping_mul(0x100000001b3));
    BigInt::from(h)
}

#[napi]
pub struct Counter {
    count: u32,
    step: u32,
}

#[napi]
impl Counter {
    #[napi(constructor)]
    pub fn new(step: Option<u32>) -> Result<Self> {
        let step = step.unwrap_or(1);
        if step == 0 {
            return Err(Error::new(Status::InvalidArg, "step must be non-zero"));
        }
        Ok(Counter { count: 0, step })
    }

    #[napi]
    pub fn bump(&mut self) -> u32 {
        self.count += self.step;
        self.count
    }

    #[napi(getter)]
    pub fn count(&self) -> u32 {
        self.count
    }
}

#[napi]
pub fn count_to(n: u32, on_tick: ThreadsafeFunction<u32>) {
    std::thread::spawn(move || {
        for i in 1..=n {
            on_tick.call(Ok(i), ThreadsafeFunctionCallMode::NonBlocking);
            std::thread::sleep(std::time::Duration::from_millis(20));
        }
    });
}
more_demo.jsJavaScript
const { checksum, Counter, countTo } = require('./index.js');

const data = Buffer.alloc(100 * 1024 * 1024, 7);
const t = process.hrtime.bigint();
const sum = checksum(data);
console.log(`checksum of ${data.length / 1048576} MB buffer: ${sum.toString(16)} in ${Math.round(Number(process.hrtime.bigint() - t) / 1e6)} ms`);

const c = new Counter(5);
console.log(c.bump(), c.bump(), c.count);
try { new Counter(0); } catch (e) { console.log(e.code, '-', e.message); }

const ticks = [];
const start = Date.now();
countTo(3, (err, i) => {
  ticks.push(i);
  if (i === 3) console.log('ticks', ticks, 'delivered over about', Math.round((Date.now() - start) / 10) * 10, 'ms');
});
console.log('countTo returned immediately');
Text
checksum of 100 MB buffer: 5f17c1a5b1622325 in 138 ms
5 10 10
InvalidArg - step must be non-zero
countTo returned immediately
ticks [ 1, 2, 3 ] delivered over about 50 ms

The checksum of a 100 MB buffer took 138 ms for a byte-at-a-time hash, with no copying, about 720 MB per second. The class kept its state in Rust between calls and threw the Rust-defined error from the constructor. countTo returned immediately, as the output order shows, and the three ticks arrived over about 40 ms from a background thread, each delivered as a normal callback on the event loop with the Node convention of an error first and the value second. The generated declarations mirror that: the callback is typed (err: Error | null, arg: number) => any. A ThreadsafeFunction keeps the process alive while it exists, which is right for a live subscription and wrong for a leaked one; call its abort method (or drop it in Rust once the thread finishes) so that Node can exit.

The rules of thumb that fall out: use a Buffer or Uint8Array for anything large, because converting a JavaScript array of numbers to a Vec copies and converts every element; keep objects crossing the boundary small and flat, because every property read is a call into the engine; and do not hold JavaScript values on another thread, because they are tied to the thread and the realm that created them. If you need a result back from background work, return a promise (as churn does) instead of passing a callback, which composes with async/await and error handling.

Testing the addon

An addon is a native library with a JavaScript API, so test it the way you would test either: the Rust logic with cargo test, and the JavaScript surface with the runner you already use. Node's built-in node:test is enough and adds no dependency. The most useful tests compare the Rust result with a plain JavaScript reference over the edge cases, and check the properties that are the reason for the addon in the first place, such as keeping the event loop alive:

test.mjsJavaScript
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';

const { fib, topWords, churn, Counter } = createRequire(import.meta.url)('./index.js');

function jsFib(n) {
  let a = 0n, b = 1n;
  const mask = (1n << 64n) - 1n;
  for (let i = 0; i < n; i++) [a, b] = [b, (a + b) & mask];
  return a;
}

test('fib matches a BigInt reference, including the 64-bit wrap', () => {
  for (const n of [0, 1, 2, 10, 50, 93, 94, 1000]) {
    assert.equal(fib(n), jsFib(n), `fib(${n})`);
  }
});

test('topWords sorts by count, then alphabetically', () => {
  assert.deepEqual(topWords('b a b a c', 3), [
    { word: 'a', count: 2 },
    { word: 'b', count: 2 },
    { word: 'c', count: 1 },
  ]);
});

test('topWords rejects n = 0 with InvalidArg', () => {
  assert.throws(() => topWords('x', 0), { code: 'InvalidArg' });
});

test('churn resolves off the main thread', async () => {
  let ticked = false;
  const timer = setTimeout(() => (ticked = true), 0);
  const result = churn(Array.from({ length: 1000 }, (_, i) => i), 20000);
  await new Promise((r) => setTimeout(r, 5));
  assert.equal(ticked, true, 'the event loop kept running');
  assert.equal(typeof (await result), 'number');
  clearTimeout(timer);
});

test('Counter keeps state in Rust', () => {
  const c = new Counter(3);
  c.bump();
  assert.equal(c.bump(), 6);
  assert.equal(c.count, 6);
});
Text
✔ fib matches a BigInt reference, including the 64-bit wrap (1.138375ms)
✔ topWords sorts by count, then alphabetically (0.551042ms)
✔ topWords rejects n = 0 with InvalidArg (0.249291ms)
✔ churn resolves off the main thread (32.71325ms)
✔ Counter keeps state in Rust (0.149541ms)
ℹ tests 5
ℹ pass 5
ℹ fail 0

The first test pins the 64-bit wrap: the reference uses BigInt with a mask, and the case list includes 93 and 94, the boundary where a Fibonacci number stops fitting in 64 bits. The third test asserts the error code, not just that something threw, since the code is the part of the contract that JavaScript callers branch on. The fourth proves the property: while the Rust task ran, a zero-delay timer fired, so the event loop was not blocked. Put the build in the test script (npm run build && node --test), and run the suite in CI against the built binary on every platform you ship, since a native addon can pass on Linux and fail to load on Windows.

Choosing between an addon, WebAssembly and worker threads

An addon is not the only way to move work off the JavaScript thread, and it is not always the best. The options differ in what they cost to build, what they can reach, and where they run:

Native addon (napi-rs)WebAssemblyworker_threads
Runs inNode onlyNode and browsersNode only
SpeedNative, with threads and SIMDClose to native, sandboxed, no direct OS accessJavaScript speed, one thread each
Can use system libraries, files, socketsYesOnly through the hostYes
DistributionOne binary per OS and CPUOne .wasm filePlain JavaScript
Failure modeA crash takes down the processA trap is an exceptionA worker error is an event
Best forHeavy compute, existing Rust crates, OS accessPortable compute, running in the browser tooParallelising code that is already fast enough JavaScript

The decision usually falls out of two questions. Does the code need to run in a browser as well? Then WebAssembly. Is the code already as fast in JavaScript as it would be in Rust, as the hash loop was here? Then worker_threads gets you the parallelism with no native toolchain to maintain. The addon is the answer when the work is both heavy and needs something only native code has: a Rust crate with no JavaScript counterpart, a system API, shared memory across threads, or the last factor of two or ten in speed. The crash mode in the table deserves weight: a segfault or abort in an addon kills the whole Node process, including every request in flight, where a bug in a worker thread only fails that worker. Keep the unsafe surface of the addon small, return errors instead of panicking, and do not accept inputs you have not validated, as the fib(-1) case showed.

Shipping it

An addon is a platform-specific binary, so distribution means building for each platform you support and getting the right one onto the user's machine. The napi-rs convention is a main package plus one small package per platform (@yourscope/fastnode-darwin-arm64 and so on), listed as optionalDependencies of the main package so that npm install fetches only the one that matches. The CLI has commands for generating those packages and for the GitHub Actions workflow that builds the matrix on real runners (macOS, Windows, Linux with glibc and with musl) and publishes. Build Linux binaries in a container with an old glibc, for the same reason as Python manylinux wheels, or users on older distributions will see GLIBC_2.xx not found at require. Napi-rs 3 can also target WebAssembly (wasm32-wasip1-threads) as a fallback for platforms without a prebuilt binary, at a speed and compatibility cost. I built only for aarch64-apple-darwin here; the cross-platform matrix is configuration of the targets list and the workflow, and should be verified in CI on each target before release.

What goes wrong

SymptomCauseFix
Cannot find module './fastnode.darwin-arm64.node'Not built for this platform, or the binary name differs from binaryNameRun npm run build on that platform; check the name
Linker errors about undefined napi_* symbolsbuild.rs missing or does not call napi_build::setup()Add it, with napi-build in build-dependencies
Callback receives one array argumentTuple type used for the callback argumentsWrap in FnArgs<(...)>
Server stalls during a callHeavy work in a synchronous #[napi] functionUse AsyncTask or an async fn
Negative or large numbers produce odd resultsInteger conversion wrapsAccept a wider type and validate
cannot find crate tokio in your own codeThe tokio_rt feature does not re-export tokio to youAdd tokio as a direct dependency with the features you use
Process aborts on a Rust panicA panic unwound across the N-API boundaryReturn Result for expected failures; treat panics as bugs

Where this does not fit

If the slow part is I/O, an addon does not help; Node already does that well. If the function is cheap and called millions of times, the cost of crossing the boundary (argument conversion, a handle scope per call) can exceed the saving, so batch: pass an array or a Buffer and process it in one call. If you want to run the same code in the browser as well, compile to WebAssembly instead, as in Rust to WebAssembly, since an addon runs only in Node. And if you only need a CLI tool in your build pipeline, ship a binary and call it. For the Python equivalent of this guide, with the same shape and different gotchas, see Python extensions with PyO3 and maturin, and for the C interface under both, calling Rust from C.

Taggednapi
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.