Node.js Native Addons with napi-rs
Sometimes a hot path in your Node.js service is simply too slow in JavaScript, or you need to reuse a battle-tested Rust crate from your existing TypeScript codebase. A native addon lets you ship compiled Rust that Node loads and calls like any other module. napi-rs is the modern, ergonomic way to build one: you annotate ordinary Rust functions with #[napi], and it generates the C glue, the loader, and even the TypeScript type definitions for you.
Quick Overview
Section titled “Quick Overview”A native addon is a compiled binary (.node file) that exposes functions, classes, and values to JavaScript through Node’s stable Node-API (formerly N-API). napi-rs is a Rust framework that turns annotated Rust functions into a Node addon: write #[napi] pub fn greet(name: String) -> String, run one build command, and require() it from Node as if it were a normal package, with generated .d.ts types so TypeScript callers get full IntelliSense.
For a TypeScript or JavaScript developer, the value proposition is concrete: keep your Node service and its ecosystem, but move CPU-bound work (parsing, hashing, image processing, compression) into Rust that runs many times faster and never blocks the event loop. Because napi-rs targets the stable Node-API ABI, a single compiled .node binary keeps working across Node major versions without recompiling, unlike the older, header-bound native-module approach.
Note: This page covers napi-rs specifically. The pure-C-ABI foundation underneath it is in FFI Basics and Calling C from Rust. The alternative addon framework, Neon, and how it compares, is in Neon. Compiling Rust for the browser instead of Node is WebAssembly territory.
TypeScript/JavaScript Example
Section titled “TypeScript/JavaScript Example”Today, when a Node.js codebase needs a native addon, the realistic options are: write painful C++ against node-addon-api, or call out to a separate binary over a child process. Here is the C++ approach, the thing napi-rs replaces. Even a trivial fibonacci function is verbose, manual, and easy to get wrong:
// addon.cc — node-addon-api (C++). This is the status quo napi-rs improves on.#include <napi.h>
// Every argument must be hand-unwrapped and type-checked by you.Napi::Value Fibonacci(const Napi::CallbackInfo& info) { Napi::Env env = info.Env();
if (info.Length() < 1 || !info[0].IsNumber()) { Napi::TypeError::New(env, "expected a number").ThrowAsJavaScriptException(); return env.Null(); }
uint32_t n = info[0].As<Napi::Number>().Uint32Value(); uint64_t a = 0, b = 1; for (uint32_t i = 0; i < n; i++) { uint64_t next = a + b; a = b; b = next; } // You must also choose the right JS number representation by hand. return Napi::BigInt::New(env, a);}
// And manually register every export into the module object.Napi::Object Init(Napi::Env env, Napi::Object exports) { exports.Set("fibonacci", Napi::Function::New(env, Fibonacci)); return exports;}
NODE_API_MODULE(addon, Init)// binding.gyp + node-gyp build, then:const { fibonacci } = require('./build/Release/addon.node');console.log(fibonacci(50)); // 12586269025nThe argument unwrapping, the type checks, the binding.gyp build descriptor, the node-gyp toolchain, the manual exports.Set registration, and the hand-written .d.ts (if you want types at all) are all on you. There is no memory safety and no borrow checker: a stray pointer here is a production segfault.
Rust Equivalent
Section titled “Rust Equivalent”Here is the entire equivalent in napi-rs. The Rust is plain Rust; the #[napi] attribute does the binding generation. Set up a library crate:
[package]name = "greet-native"version = "0.1.0"edition = "2024" # cargo new selects the latest stable edition automatically
[dependencies]napi = { version = "3", features = ["napi9"] }napi-derive = "3"
[build-dependencies]napi-build = "2"
[lib]crate-type = ["cdylib"] # produce a C-compatible dynamic library Node can load// build.rs — runs napi's build-time setup (configures the linker for Node-API).fn main() { napi_build::setup();}use napi_derive::napi;
#[napi]pub fn fibonacci(n: u32) -> u64 { let (mut a, mut b) = (0u64, 1u64); for _ in 0..n { let next = a + b; a = b; b = next; } a}
#[napi]pub fn greet(name: String) -> String { format!("Hello, {name}!")}Build it with the napi CLI (installed as a dev dependency: npm install --save-dev @napi-rs/cli):
# binaryName is read from the "napi" block in package.json; see below.npx napi build --platform --releaseThat single command compiles the crate, copies the resulting greet-native.<platform>.node next to your JS, and auto-generates index.js (a loader that picks the right binary) plus index.d.ts. Here is the real generated index.d.ts from the code above:
/* auto-generated by NAPI-RS *//* eslint-disable */export declare function fibonacci(n: number): bigint
export declare function greet(name: string): stringAnd calling it from Node:
import { fibonacci, greet } from './index.js';
console.log(greet('Ada'));console.log('fib(50) =', fibonacci(50));Real output from node test.mjs:
Hello, Ada!fib(50) = 12586269025nNo binding.gyp, no manual argument unwrapping, no hand-written types. The Rust function signature is the binding contract.
Detailed Explanation
Section titled “Detailed Explanation”Let’s walk through what each piece does and why, contrasting with the JavaScript/C++ side you know.
The #[napi] attribute is a procedural macro
Section titled “The #[napi] attribute is a procedural macro”#[napi] is not a decorator (decorators in TypeScript wrap a value at runtime). It is a procedural macro that runs at compile time and rewrites your function: it emits an extern "C" wrapper with the exact signature Node-API expects, generates code to convert each JavaScript argument into the Rust type and the Rust return value back into a JS value, and registers the export in the module’s init function. The macro machinery is the same family covered in Macros; it just happens to target Node-API. Your fibonacci stays a normal, testable, standalone Rust function.
Type conversions are automatic and bidirectional
Section titled “Type conversions are automatic and bidirectional”When JS calls fibonacci(50), napi-rs converts the JS number 50 into a Rust u32, runs your loop, then converts the u64 result back to a JS value. The mapping is deliberate and worth memorizing:
u32parameter ← JSnumber. If the caller passes something that is not a number, napi-rs throws a JS error before your code runs: you never see a bad value.u64return → JSbigint, which is why the output is12586269025n(note thensuffix). This is correct, not a quirk: a JSnumberis an IEEE-754f64and silently loses integer precision above 2^53, so napi-rs maps the 64-bit integer types tobigintto preserve every bit. Contrast this with WebAssembly, where the boundary is even more restrictive.Stringparameter/return ↔ JSstring, with a UTF-8/UTF-16 conversion at the boundary.
crate-type = ["cdylib"] and build.rs
Section titled “crate-type = ["cdylib"] and build.rs”A normal Rust binary or rlib is no use to Node. cdylib tells the compiler to produce a C-compatible dynamic library — a .dylib/.so/.dll exposing a stable C ABI, which is exactly what Node’s process.dlopen can load. This is the same cdylib you would use for any C consumer; see FFI Basics. The build.rs calling napi_build::setup() configures the linker so the symbols Node-API needs resolve correctly on each platform.
The napi9 feature selects the Node-API version
Section titled “The napi9 feature selects the Node-API version”features = ["napi9"] declares which Node-API version your addon targets (Node-API 9 is available in Node 18.17+, 20.3+, and 21+). Node-API is a stability contract: a binary built against Node-API 9 keeps loading on every future Node release that supports version 9 or higher, with no recompilation. This is the headline reliability win over the old node-gyp/V8-header approach, where a Node upgrade routinely broke prebuilt binaries.
The package.json napi block
Section titled “The package.json napi block”The CLI reads a small config block to know what to name the binary:
{ "name": "greet-native", "main": "index.js", "napi": { "binaryName": "greet-native" }}binaryName is the prefix of the generated .node file (greet-native.darwin-arm64.node, greet-native.linux-x64-gnu.node, and so on). The generated index.js loader inspects process.platform/process.arch at runtime and requires the matching binary: that is how one published package serves many platforms.
Key Differences
Section titled “Key Differences”| Concern | node-addon-api (C++) | napi-rs (Rust) |
|---|---|---|
| Argument unwrapping | Manual info[0].As<...>() + type checks | Automatic from the Rust signature |
| Type errors | You throw them yourself, or crash | Thrown by the framework before your code runs |
| Memory safety | None — UB and segfaults are on you | Borrow checker applies; unsafe is rare |
| TypeScript types | Hand-written .d.ts, easily out of date | Generated from the signature, always in sync |
| Build system | binding.gyp + node-gyp (Python toolchain) | cargo + @napi-rs/cli |
| 64-bit integers | You pick Number vs BigInt manually | i64/u64 map to bigint automatically |
| ABI stability | Tied to Node-API if you use it carefully | Node-API by construction; cross-version stable |
| Async | Manual AsyncWorker boilerplate | async fn returns a JS Promise |
A few conceptual points a TypeScript developer should internalize:
- The boundary is a real cost. Every call from JS into the addon performs argument conversion and a function-pointer call through Node-API. For tiny, frequently-called functions that overhead can dominate; the win comes from doing meaningful work per call. This mirrors the WASM boundary cost discussed in WebAssembly performance.
- Naming is converted. Rust’s
snake_caseexports become JavaScript’scamelCaseautomatically:fetch_delayedbecomesfetchDelayed. The generated.d.tsreflects this. - Rust errors become thrown JS errors. Returning
napi::Result<T>mapsOk(v)to a normal return andErr(e)to a thrown JSError. There is no checked-exception equivalent; the TypeScript signature shows only the success type, exactly like a function that maythrow. #[napi]is compile-time codegen, not a runtime wrapper. Unlike a TypeScript decorator that executes when the class is defined, the macro has fully expanded before the binary exists. There is no runtime reflection cost.
Common Pitfalls
Section titled “Common Pitfalls”Pitfall 1: Returning a type napi-rs cannot convert
Section titled “Pitfall 1: Returning a type napi-rs cannot convert”Not every Rust type can cross the boundary. If you return something without a JS representation — say std::net::Ipv4Addr — the macro-generated conversion fails to compile:
use napi_derive::napi;
#[napi]pub fn bad() -> std::net::Ipv4Addr { // does not compile (E0277) std::net::Ipv4Addr::new(127, 0, 0, 1)}The real cargo build error:
error[E0277]: the trait bound `Ipv4Addr: ToNapiValue` is not satisfied --> src/lib.rs:4:17 |4 | pub fn bad() -> std::net::Ipv4Addr { | ^^^^^^^^^^^^^^^^^^ the trait `JsValue<'_>` is not implemented for `Ipv4Addr` | = help: the following other types implement trait `JsValue<'env>`: Array<'env> ArrayBuffer<'env> BigInt64ArraySlice<'env> ... and 21 others = note: required for `Ipv4Addr` to implement `ToNapiValue`The fix is to return a type that does convert: a String (addr.to_string()), a struct annotated with #[napi(object)], or a numeric/Vec/HashMap type that napi-rs supports.
Pitfall 2: Forgetting crate-type = ["cdylib"]
Section titled “Pitfall 2: Forgetting crate-type = ["cdylib"]”Without the [lib] crate-type = ["cdylib"] entry, Cargo builds an rlib (a Rust-only static library). The compile may succeed, but there is no loadable dynamic library, and Node cannot require anything. Always set cdylib for an addon crate.
Pitfall 3: Expecting a JS number to hold a 64-bit integer
Section titled “Pitfall 3: Expecting a JS number to hold a 64-bit integer”If you return u64 and a TypeScript caller writes const n: number = fibonacci(50), the type checker will complain because the generated type is bigint, not number:
import { fibonacci } from './index.js';const n: number = fibonacci(50); // TS error: Type 'bigint' is not assignable to type 'number'.This is napi-rs protecting you. A JS number cannot represent 12586269025 faithfully through arithmetic above 2^53, losing precision, not wrapping. If you genuinely only need values that fit in 2^53, return i32/f64 from Rust so the JS side gets a plain number.
Pitfall 4: Passing the wrong argument type from JS
Section titled “Pitfall 4: Passing the wrong argument type from JS”You do not need to write any type-checking code; napi-rs rejects bad arguments at the boundary. Calling greet(42) when greet expects a String throws at runtime:
greet(42); // throwsThe real thrown message:
Failed to convert JavaScript value `Number 42 ` into rust type `String`The pitfall is assuming you still need manual typeof guards inside the addon (you do not), and forgetting that untyped JS callers can still trigger these throws, so wrap addon calls in try/catch at the JS boundary just as you would any throwing function.
Pitfall 5: Doing long synchronous work and blocking the event loop
Section titled “Pitfall 5: Doing long synchronous work and blocking the event loop”A #[napi] function with a non-async signature runs on the calling JavaScript thread. A multi-second computation there freezes the whole Node event loop, exactly as a long synchronous JS loop would. The fix is an async fn (next section) or napi-rs’s AsyncTask, which run the work off-thread and resolve a Promise.
Best Practices
Section titled “Best Practices”- Keep the unit of work large. Cross the boundary as few times as possible: prefer
process(records: Vec<Record>) -> Vec<Result>over calling a per-record function in a JS loop a million times. The conversion overhead is per-call. - Use
#[napi(object)]for plain data,#[napi]classes for stateful handles. A struct tagged#[napi(object)]becomes a plain JS object (passed by value, with a generatedinterface). A struct tagged#[napi]becomes a JSclasswhose instance holds a live Rust value: use it when the object owns resources or state. - Return
napi::Result<T>and build errors withError::new(Status::…, msg)so failures surface as proper JS exceptions with a.code, instead of relying onunwrap()/panic!. Framework and panic-profile behavior differs: a panic may be caught and converted, or it may abort the process at a non-unwinding ABI boundary; neither is a stable error contract for JavaScript callers. - Make blocking work
async. If the Rust work is CPU-heavy or does blocking I/O, use anasync fn(with napi’sasyncfeature) so it does not stall the event loop. - Commit the generated
index.d.ts(or regenerate it in CI) so TypeScript consumers always get accurate types, and verify it in code review when signatures change. - Pin to a Node-API version (
napi9) deliberately and document the minimum Node version it implies. Higher versions give access to more APIs but require newer Node. - For publishing, build per-platform binaries in CI (the CLI’s
--targetflag and the generated GitHub Actions workflow handle the matrix) and ship them asoptionalDependenciesso each consumer downloads only their platform’s binary.
Real-World Example
Section titled “Real-World Example”A realistic reason to reach for a native addon: a Node service has a hot computation that dominates a request, and you want to move it to Rust without rewriting the service. This example shows the three patterns you will actually use — a data-returning struct, fallible parsing, and asynchronous work — plus a measured speedup.
use napi::bindgen_prelude::*;use napi_derive::napi;
/// A plain-data result. `#[napi(object)]` makes it a JS object with a/// generated TypeScript `interface`.#[napi(object)]pub struct Stats { pub count: u32, pub total: f64, pub mean: f64,}
#[napi]pub fn summarize(values: Vec<f64>) -> Stats { let count = values.len() as u32; let total: f64 = values.iter().sum(); let mean = if count == 0 { 0.0 } else { total / count as f64 }; Stats { count, total, mean }}
/// Fallible work. `Result<u16>` maps `Err` to a thrown JS error;/// the generated `.d.ts` shows only the success type `number`.#[napi]pub fn parse_port(s: String) -> Result<u16> { s.parse::<u16>() .map_err(|e| Error::new(Status::InvalidArg, format!("invalid port {s:?}: {e}")))}
/// Asynchronous work. An `async fn` returns a JS `Promise<...>` and runs/// off the JS thread, so it never blocks the event loop. Requires the/// `async` feature on the `napi` crate (and a Tokio dependency here).#[napi]pub async fn fetch_delayed(ms: u32) -> Result<String> { tokio::time::sleep(std::time::Duration::from_millis(ms as u64)).await; Ok(format!("done after {ms}ms"))}
/// A stateful handle. `#[napi]` on a struct + impl produces a JS class.#[napi]pub struct Counter { value: i32,}
#[napi]impl Counter { #[napi(constructor)] pub fn new(start: i32) -> Self { Counter { value: start } }
#[napi] pub fn increment(&mut self) -> i32 { self.value += 1; self.value }
#[napi(getter)] pub fn value(&self) -> i32 { self.value }}The Cargo.toml adds the async feature and Tokio for the async example:
# Cargo.toml (additions)[dependencies]napi = { version = "3", default-features = false, features = ["napi9", "async"] }napi-derive = "3"tokio = { version = "1", features = ["rt", "time", "rt-multi-thread"] }The CLI regenerates the full index.d.ts from these signatures. Note the camelCase conversion, the Promise, the interface, and the class:
/* auto-generated by NAPI-RS *//* eslint-disable */export declare class Counter { constructor(start: number) increment(): number get value(): number}
export declare function fetchDelayed(ms: number): Promise<string>
export declare function fibonacci(n: number): bigint
export declare function greet(name: string): string
export declare function parsePort(s: string): number
export interface Stats { count: number total: number mean: number}
export declare function summarize(values: Array<number>): StatsCalling all of it from Node, including the error and async paths:
import { summarize, parsePort, fetchDelayed, Counter } from './index.js';
console.log(summarize([10, 20, 30, 40]));console.log('parsePort("8080") =', parsePort('8080'));
try { parsePort('70000'); // out of u16 range -> thrown error} catch (err) { console.log('threw:', err.message); console.log('err.code:', err.code);}
console.log('await fetchDelayed(50) =>', await fetchDelayed(50));
const c = new Counter(10);console.log('initial:', c.value, '| after increment:', c.increment());Real output from node test.mjs:
{ count: 4, total: 100, mean: 25 }parsePort("8080") = 8080threw: invalid port "70000": number too large to fit in target typeerr.code: InvalidArgawait fetchDelayed(50) => done after 50msinitial: 10 | after increment: 11And the payoff — a microbenchmark of the native fibonacci against an equivalent pure-JS BigInt version, one million calls each (fib(90)):
// bench.mjs (excerpt) — fibJs uses BigInt to match the u64 return type.import { fibonacci } from './index.js';function fibJs(n) { let a = 0n, b = 1n; for (let i = 0; i < n; i++) { const t = a + b; a = b; b = t; } return a; }// ...time 1,000,000 calls of each with process.hrtime.bigint()...Real output from node bench.mjs on this machine (Node v22, release build, Apple Silicon):
JS : 1047.8 msRust: 57.6 msAbout an 18x speedup, and importantly, that figure includes a million boundary crossings, so it is a fair, honest measurement rather than a microbenchmark that hides the FFI cost. The bigger the work per call, the closer you get to raw Rust speed.
Tip: Benchmark your workload before committing to a native addon. If a function is cheap and called in a tight JS loop, the per-call boundary overhead can erase the win. See Performance for how to measure rigorously with
criterion.
Further Reading
Section titled “Further Reading”- napi.rs — official documentation: the canonical guide, including the full type-conversion reference and the
@napi-rs/clicommands. - napi-rs GitHub repository — source, examples, and the supported-types matrix.
- Node-API documentation — the stable C API that napi-rs targets, and the Node-API version-to-Node-version table.
napicrate on docs.rs — the Rust API surface:Error,Status,bindgen_prelude, async tasks, and buffers.- Within this guide:
- Neon: the alternative Node-addon framework, and a head-to-head comparison with napi-rs.
- FFI Basics and Calling C from Rust: the
extern "C"/cdylibfoundation napi-rs is built on. - What
unsafeReally Means and Building Safe Abstractions: how napi-rs hidesunsafeboundary code behind a safe API. - When to Use Unsafe and FFI: deciding whether a native addon is actually warranted.
- WebAssembly and WASM performance: the in-browser / portable alternative when you do not need native Node.
- Macros — how attribute macros like
#[napi]generate code at compile time. - Error Handling — the
Resulttype that maps to thrown JS errors. - Async and Performance — async fundamentals and how to measure the speedup honestly.
Exercises
Section titled “Exercises”Exercise 1: Export a function and call it from Node
Section titled “Exercise 1: Export a function and call it from Node”Difficulty: Beginner
Objective: Create a working napi-rs addon from scratch and call it from Node.
Instructions: Set up a cdylib crate with the napi, napi-derive, and napi-build dependencies, a build.rs calling napi_build::setup(), and a package.json with a napi.binaryName. Export a function word_count(text: String) -> u32 that returns the number of whitespace-separated words. Build it with npx napi build --platform --release and call it from a .mjs file. Predict what the generated .d.ts signature will be before you look.
Solution
use napi_derive::napi;
#[napi]pub fn word_count(text: String) -> u32 { text.split_whitespace().count() as u32}fn main() { napi_build::setup();}# Cargo.toml (key parts)[dependencies]napi = { version = "3", features = ["napi9"] }napi-derive = "3"
[build-dependencies]napi-build = "2"
[lib]crate-type = ["cdylib"]The generated index.d.ts (a u32 maps to JS number, not bigint):
export declare function wordCount(text: string): numberimport { wordCount } from './index.js';console.log(wordCount('the quick brown fox')); // 4Real output:
4Exercise 2: Return a struct and surface errors
Section titled “Exercise 2: Return a struct and surface errors”Difficulty: Intermediate
Objective: Use #[napi(object)] for structured output and napi::Result for fallible work.
Instructions: Export parse_rgb(hex: String) -> Result<Rgb> where Rgb is a #[napi(object)] struct with r, g, b fields (each u8). Accept strings like "#ff8800"; on a malformed input, return an Err built with Error::new(Status::InvalidArg, ...) so Node sees a thrown error with a code. Call it from Node for both a valid and an invalid input, catching the throw.
Solution
use napi::bindgen_prelude::*;use napi_derive::napi;
#[napi(object)]pub struct Rgb { pub r: u8, pub g: u8, pub b: u8,}
#[napi]pub fn parse_rgb(hex: String) -> Result<Rgb> { let s = hex.strip_prefix('#').unwrap_or(&hex); if s.len() != 6 { return Err(Error::new( Status::InvalidArg, format!("expected 6 hex digits, got {:?}", hex), )); } let parse = |slice: &str| { u8::from_str_radix(slice, 16) .map_err(|e| Error::new(Status::InvalidArg, format!("bad hex {slice:?}: {e}"))) }; Ok(Rgb { r: parse(&s[0..2])?, g: parse(&s[2..4])?, b: parse(&s[4..6])?, })}The generated index.d.ts:
export interface Rgb { r: number g: number b: number}export declare function parseRgb(hex: string): Rgbimport { parseRgb } from './index.js';
console.log(parseRgb('#ff8800')); // { r: 255, g: 136, b: 0 }
try { parseRgb('nope');} catch (err) { console.log('code:', err.code, '| message:', err.message);}Real output:
{ r: 255, g: 136, b: 0 }code: InvalidArg | message: expected 6 hex digits, got "nope"Note: napi-rs validates that each
u8field is in range when converting back to JS, so you cannot accidentally return a value outside0..=255.
Exercise 3: Async work that returns a Promise
Section titled “Exercise 3: Async work that returns a Promise”Difficulty: Advanced
Objective: Move blocking work off the event loop with an async fn, returning a JS Promise.
Instructions: Enable the async feature on the napi crate and add tokio. Export async fn hash_rounds(input: String, rounds: u32) -> Result<String> that, for rounds iterations, folds the input into a running u64 hash (any simple mixing is fine — e.g. multiply-add over the bytes) with a small tokio::time::sleep per round to simulate work, then returns the final hash as a hex string. Confirm from Node that the returned value is a Promise you can await, and that the generated .d.ts says Promise<string>.
Solution
use napi::bindgen_prelude::*;use napi_derive::napi;
#[napi]pub async fn hash_rounds(input: String, rounds: u32) -> Result<String> { let mut hash: u64 = 0xcbf2_9ce4_8422_2325; // FNV-1a offset basis for _ in 0..rounds { for &byte in input.as_bytes() { hash ^= byte as u64; hash = hash.wrapping_mul(0x100_0000_01b3); // FNV prime } // Yield to the runtime; in real code this would be real I/O or CPU work. tokio::time::sleep(std::time::Duration::from_millis(1)).await; } Ok(format!("{hash:016x}"))}# Cargo.toml (key parts)[dependencies]napi = { version = "3", default-features = false, features = ["napi9", "async"] }napi-derive = "3"tokio = { version = "1", features = ["rt", "time", "rt-multi-thread"] }The generated index.d.ts:
export declare function hashRounds(input: string, rounds: number): Promise<string>import { hashRounds } from './index.js';
const promise = hashRounds('payload', 5);console.log('is a Promise:', promise instanceof Promise); // trueconsole.log('hash:', await promise); // 16-hex-digit stringReal output (the exact hash depends on your mixing function; with the FNV-1a code above):
is a Promise: truehash: 2227eb666952eee5The key win: while the addon is computing, the Node event loop keeps running other tasks. A non-async version of the same loop would freeze the process for the duration. See Async for the underlying model, and remember that Rust futures are lazy (they do nothing until polled by the runtime), the opposite of an eager JavaScript Promise.