DEV Community

Daanyaal Sobani
Daanyaal Sobani

Posted on

Calling Rust from Node.js: A Practical Guide to NAPI-RS

Note: I used Claude to write this whole article based on my github and youtube video linked at the end.

You have a Node.js backend. Somewhere in it, there's a hot path — maybe image processing, maybe a parser, maybe a prime-checker for some cursed reason — and you'd like it to be faster. You've heard Rust is fast. But you don't want to rewrite your whole backend in Rust, and you really don't want to spin up a separate Rust web server just to expose one function over HTTP.

Enter NAPI-RS: a framework for compiling Rust code into a native Node.js addon that you can require() like any other module. No IPC. No sockets. No serialization. Your Rust function becomes a JavaScript function, living in the same process, called with a direct function pointer jump.

This post walks through building a minimal demo end-to-end, then unpacks what's actually going on at the ABI / dynamic-linking level. If you prefer video, there's a YouTube walkthrough and the full code on GitHub.

What we're building

A tiny Express server with two routes, both backed by Rust:

  • GET /plus100/:n — returns n + 100
  • GET /is-prime/:n — returns whether n is prime

Nothing fancy. The point is the integration pattern, not the algorithm.

Prerequisites

  • Node.js 18+ (nodejs.org)
  • Rust via rustup
  • A C toolchain for linking:
    • Windows: Visual Studio Build Tools with "Desktop development with C++"
    • macOS: xcode-select --install
    • Linux: sudo apt install build-essential (or your distro's equivalent)

Sanity check:

node --version
cargo --version
Enter fullscreen mode Exit fullscreen mode

If those both print versions, you're ready.

Step 1: Install the NAPI-RS CLI

npm install -g @napi-rs/cli
Enter fullscreen mode Exit fullscreen mode

You could also use npx @napi-rs/cli each time, but installing globally gives you the shorter napi command.

Step 2: Create the outer Node project

mkdir my-app && cd my-app
npm init -y
npm install express
Enter fullscreen mode Exit fullscreen mode

Standard Express setup. Nothing unusual so far.

Step 3: Scaffold the Rust addon

napi new rust_functions
Enter fullscreen mode Exit fullscreen mode

It'll ask a few questions. Reasonable answers:

  • Package name: rust_functions
  • Minimum Node-API version: napi9 (default — works on Node 18.17+)
  • Target: pick your current platform (e.g., x86_64-pc-windows-msvc on Windows, aarch64-apple-darwin on Apple Silicon)
  • GitHub Actions: no (keeps things simple)
  • License: MIT

This creates a rust_functions/ subfolder with a full scaffold — Cargo.toml, src/lib.rs, its own package.json, a build script, and some npm glue.

Install its dependencies:

cd rust_functions
npm install
cd ..
Enter fullscreen mode Exit fullscreen mode

The layout now looks like this:

my-app/
├── package.json
└── rust_functions/
    ├── Cargo.toml
    ├── src/lib.rs        ← your Rust code goes here
    ├── package.json
    ├── build.rs
    └── ...
Enter fullscreen mode Exit fullscreen mode

Note on the target prompt: the target you pick during scaffolding only affects what CI would prebuild if you published to npm. It doesn't restrict who can run napi build locally. A teammate on macOS can clone this project and npm run build without any changes — napi auto-detects their platform.

Step 4: Write the Rust

Open rust_functions/src/lib.rs and replace it with:

#![deny(clippy::all)]

use napi_derive::napi;

#[napi(js_name = "plus100")]
pub fn plus_100(input: u32) -> u32 {
  input + 100
}

#[napi(js_name = "isPrime")]
pub fn is_prime(n: u32) -> bool {
  if n < 2 { return false; }
  if n < 4 { return true; }
  if n % 2 == 0 { return false; }
  let mut i: u32 = 3;
  while i * i <= n {
    if n % i == 0 { return false; }
    i += 2;
  }
  true
}
Enter fullscreen mode Exit fullscreen mode

A few things worth calling out:

The #[napi] macro is the whole magic. It wraps your function in the FFI glue Node needs to call it — we'll dig into that later. Without the macro, this is just plain Rust.

js_name controls the exported name. By default, NAPI-RS converts snake_casecamelCase, so is_prime would naturally become isPrime. But names with numbers can convert in surprising ways (e.g., plus_100 loses its underscore and becomes plus100), so setting js_name explicitly is the safest move.

Why u32 and not u64? NAPI-RS doesn't auto-convert u64 because it doesn't fit cleanly into JavaScript's native number or BigInt types. For this demo, u32 (max ~4.3 billion) is more than enough. If you need larger ranges, use i64 (which maps to BigInt on the JS side).

Why mut i? Rust variables are immutable by default. Since we're reassigning i += 2 each loop iteration, we have to opt into mutability with mut. Forgetting this is the #1 early-Rust error.

Step 5: Link the addon into the outer app

From the outer my-app/ folder:

npm install ./rust_functions
Enter fullscreen mode Exit fullscreen mode

This doesn't copy anything. It creates a symlink at node_modules/rust_functions pointing back to your rust_functions/ folder, and adds an entry in your outer package.json:

"dependencies": {
  "rust_functions": "file:rust_functions"
}
Enter fullscreen mode Exit fullscreen mode

The symlink means that whenever you rebuild the Rust addon, the outer app sees the new binary immediately. No npm install needed on every change.

Step 6: Add build scripts

Edit the outer package.json and add these scripts:

{
  "scripts": {
    "start": "node server.js",
    "build:rust": "cd rust_functions && npm run build",
    "dev": "npm run build:rust && node server.js"
  }
}
Enter fullscreen mode Exit fullscreen mode

Now from the outer folder:

npm run build:rust
Enter fullscreen mode Exit fullscreen mode

You'll see cargo compile things, and when it finishes, there'll be a new file in rust_functions/ with a name like:

  • rust_functions.win32-x64-msvc.node (Windows)
  • rust_functions.darwin-arm64.node (Apple Silicon Mac)
  • rust_functions.linux-x64-gnu.node (Linux x64)

That file is your compiled Rust. It's literally a DLL on Windows, a dylib on macOS, an .so on Linux — just with a .node extension so Node knows to treat it as a native addon.

Step 7: Write the server

Create server.js:

const express = require('express');
const { plus100, isPrime } = require('rust_functions');

const app = express();

app.get('/plus100/:n', (req, res) => {
  const n = Number(req.params.n);
  if (!Number.isFinite(n)) {
    return res.status(400).json({ error: 'n must be a number' });
  }
  res.json({ input: n, result: plus100(n) });
});

app.get('/is-prime/:n', (req, res) => {
  const n = Number(req.params.n);
  if (!Number.isFinite(n) || n < 0) {
    return res.status(400).json({ error: 'n must be a non-negative number' });
  }
  res.json({ n, isPrime: isPrime(n) });
});

app.listen(3000, () => console.log('listening on 3000'));
Enter fullscreen mode Exit fullscreen mode

Two things to notice:

  1. The destructuring with { } matters. const { plus100 } = require(...) pulls the function off the module's exports object. Without the braces, you'd get the whole module object and plus100(5) would fail with "not a function." Easy mistake to make.

  2. There's no indication these are Rust functions. plus100 and isPrime are just regular JavaScript functions as far as your code is concerned. The whole point of NAPI-RS is that the language boundary disappears once you've crossed it.

Step 8: Run it

npm start
Enter fullscreen mode Exit fullscreen mode

In another terminal:

curl http://localhost:3000/plus100/42
# {"input":42,"result":142}

curl http://localhost:3000/is-prime/1000000007
# {"n":1000000007,"isPrime":true}
Enter fullscreen mode Exit fullscreen mode

That's it. You're calling Rust from Express.

The development loop

The magic moment — edit rust_functions/src/lib.rs, add a new function, then:

npm run dev
Enter fullscreen mode Exit fullscreen mode

This rebuilds the Rust addon and restarts the server in one command. Change Rust → rebuild → hit the new endpoint. The iteration cycle is surprisingly fast: cargo does incremental compilation, and NAPI's build step just wraps it.


Now for the part most tutorials skip: what is actually happening?

What NAPI-RS is doing, under the hood

Node-API (N-API): the stable C ABI

Node.js has always had a way to run native code — things like fs, crypto, and zlib aren't written in JavaScript. But historically, writing your own native addon meant targeting V8 directly (V8 is the JavaScript engine inside Node). V8's internals change between versions, so every Node release broke every native addon. It was painful.

Node-API (N-API) fixed this by exposing a stable C ABI for native addons. An addon compiled against N-API version 9 keeps working on every future Node version that supports N-API 9 or higher — no rebuild needed. That stability is the whole reason this approach is usable.

ABI = Application Binary Interface. It's the binary-level contract between two pieces of compiled code: how arguments are passed in registers, how structs are laid out in memory, how symbols are named. Two binaries can only call each other if they agree on ABI. Node-API is that agreement, written down and frozen.

Why C specifically? Because C has a simple, well-defined ABI that every systems language can speak. Rust can produce code with C ABI (via extern "C" and #[repr(C)]). So can Go, Zig, even plain assembly. By exposing a C interface, Node makes itself reachable from basically any language that can compile to native code.

NAPI-RS: the Rust wrapper

N-API is a C API. Writing against it directly from Rust is verbose, unsafe FFI code — raw pointers, manual type conversions, manual error handling. NAPI-RS is a Rust framework that generates all that glue code for you.

When you write:

#[napi]
pub fn plus_100(input: u32) -> u32 {
  input + 100
}
Enter fullscreen mode Exit fullscreen mode

The macro expands (roughly) into:

// Your original function, unchanged
pub fn plus_100(input: u32) -> u32 { input + 100 }

// Generated C-ABI wrapper
#[no_mangle]
pub extern "C" fn __napi_wrapper_plus_100(
  env: napi_env,
  info: napi_callback_info,
) -> napi_value {
  // 1. Extract arguments from JS
  let args = napi_get_cb_info(env, info, ...);
  let input: u32 = napi_get_value_uint32(env, args[0]);

  // 2. Call your actual Rust function
  let result = plus_100(input);

  // 3. Convert the result back to a JS value
  napi_create_uint32(env, result)
}

// Registration entry: called when Node loads the .node file
#[no_mangle]
pub extern "C" fn napi_register_module_v1(
  env: napi_env,
  exports: napi_value,
) -> napi_value {
  let fn_value = napi_create_function(env, "plus100", __napi_wrapper_plus_100);
  napi_set_named_property(env, exports, "plus100", fn_value);
  exports
}
Enter fullscreen mode Exit fullscreen mode

That's the FFI boilerplate you'd otherwise write by hand. The #[napi] macro generates it so you don't have to.

The lifecycle of a require('rust_functions')

When your server does require('rust_functions'), here's what happens:

  1. Node resolves the module to rust_functions/index.js (because package.json says "main": "index.js").
  2. index.js is auto-generated by napi and contains platform-detection logic. It inspects process.platform and process.arch, then does require('./rust_functions.win32-x64-msvc.node') (or whichever binary matches the current machine).
  3. Node sees the .node extension and invokes its native module loader. On Windows this calls LoadLibraryEx(), on Linux/macOS it calls dlopen(). Same syscalls a C program would use.
  4. The OS loads the DLL into the Node process's memory space. Your Rust code, compiled to machine instructions, now lives in Node's address space.
  5. Node looks up the symbol napi_register_module_v1 (via GetProcAddress on Windows, dlsym on Unix) and calls it.
  6. The registration function runs, creates JS function objects backed by your C wrappers, attaches them to an exports object.
  7. That exports object is what require() returns. Your JS sees { plus100: [Function], isPrime: [Function] }.

From there, calling plus100(5) is a direct function call. V8 invokes the wrapper's C function pointer, the wrapper converts 5 to a Rust u32, your Rust runs, the wrapper converts 105 back to a JS number, done. All in the same process. No IPC, no serialization.

Why this is so fast

Most "call X from Y" solutions involve crossing a process boundary. Let's compare, roughly, for a trivial function call:

Approach Per-call cost
NAPI-RS addon (same process) ~100–500 ns
Long-lived Rust process + JSON over pipes ~50–200 μs
Localhost HTTP sidecar ~500 μs – 2 ms
Spawning a fresh Rust subprocess each call 1–50 ms

That's often a 1000× difference or more. The reason is that everything besides NAPI involves some combination of: kernel-mediated IPC, serializing data into bytes, deserializing on the other side, possibly starting up whole new processes. NAPI skips all of it by collapsing Rust and Node into a single process. The "boundary" between languages becomes just a couple of type conversions on a function call.

This is the whole point. If you only need cross-language integration occasionally or for coarse-grained batch work, a subprocess or HTTP sidecar is fine. But if you need to call Rust in a hot loop — say, processing every request, or in a tight inner loop — NAPI is usually the only approach that doesn't get destroyed by overhead.

The tradeoff

NAPI isn't free. In exchange for speed, you accept:

  • Coupling: if your Rust panics and isn't caught, it can crash the entire Node process. Subprocesses isolate crashes; NAPI doesn't.
  • Platform-specific binaries: your .node file only runs on the OS and architecture it was compiled for. Each user rebuilds locally (or you ship multiple prebuilt binaries for different platforms).
  • FFI conversion costs: simple primitives are cheap, but passing large structured data (big strings, nested objects) incurs serialization-like costs in the conversion layer. Not as bad as JSON over a pipe, but not free.
  • Build complexity: you now need a C toolchain and Rust installed to build the project.

For performance-critical code paths, these are usually acceptable. For "I want to use one Rust library once a day in a cron job," spawning a subprocess is simpler.

Why there are no name collisions

A natural question: what if two different NAPI addons both export a function named compute?

No collision. Each .node file is loaded via dlopen with its own handle, into its own symbol namespace. Node calls each addon's napi_register_module_v1 separately, and each builds its own exports object. In JS:

const a = require('addon-a');   // { compute: [Function], ... }
const b = require('addon-b');   // { compute: [Function], ... }
a.compute(5);   // calls addon-a's Rust
b.compute(5);   // calls addon-b's Rust
Enter fullscreen mode Exit fullscreen mode

Each is a property on a different object. The namespacing is exactly the same as for regular JS modules — because at the require() level, there's no difference.

The big reframe

The model most people start with is: "Rust is a separate program; I'd have to shell out to it." NAPI flips this. Rust code becomes part of your Node process. It's not a tool you're invoking, it's a library you've linked. Same process, same memory, same lifetime.

This is dynamic linking, and it's the same technology operating systems have supported since the 1980s. require('./foo.node') is basically just a JavaScript-friendly wrapper around LoadLibrary("foo.dll"). The novelty isn't the mechanism — it's that NAPI-RS makes it ergonomic enough that you can write normal idiomatic Rust and have it show up as normal idiomatic JavaScript.

When to reach for NAPI-RS

Good fits:

  • Hot-path computation: parsing, hashing, compression, image processing, pathfinding — anything called frequently where Rust's speed pays off.
  • Wrapping existing Rust libraries: if someone's already written a great Rust crate for what you need, NAPI-RS exposes it to Node cheaply.
  • Portable performance wins within a Node codebase: you get Rust speed without restructuring your application.

Less good fits:

  • One-off CLI-style tools: child_process.spawn() is simpler.
  • Services with independent scaling or deployment needs: a separate HTTP service is more flexible.
  • I/O-bound workloads: Rust's advantage is CPU; for I/O-bound Node code, the event loop and fast I/O libraries already handle things well.

What to explore next

  • Async Rust functions: NAPI-RS supports async fn with #[napi] — calls return JS Promises and run on a worker thread so they don't block Node's event loop.
  • Passing structs: #[napi(object)] on a Rust struct lets you pass it directly to/from JS as a plain object, with automatic field conversion.
  • Publishing to npm: the napi prepublish workflow handles packaging prebuilt binaries for multiple platforms so your users don't need Rust installed.
  • WebAssembly as an alternative: if you need portability (same binary on every platform) or sandboxing (untrusted code, plugins), WASM trades a bit of performance and access for those properties.

Resources


If you found this useful, drop a comment below — I write occasional deep dives on systems-adjacent topics when something catches my eye. Next up I'm thinking about digging into WebAssembly as an alternative path, or how Node's event loop actually works beyond the meme diagram. Let me know what you'd want to read.

Top comments (0)