When JavaScript hits the wall
You're running a Node.js server. The CPU usage spikes every time a user uploads a large image. The event loop freezes. Requests queue up. You've profiled the code and found the bottleneck: a pure-JavaScript image processing loop. You know Rust can crush this workload, but you don't want to rewrite the entire app. You need a way to hand off the heavy lifting to Rust and get the result back without blocking the JavaScript thread.
This is a classic Foreign Function Interface (FFI) scenario. You have a fast, safe language doing the work and a dynamic, event-driven language orchestrating the flow. The challenge is making them talk without leaking memory or crashing the runtime.
The bridge between worlds
JavaScript runs in a runtime like V8 with its own garbage collector and object model. Rust manages memory with ownership and borrows. They don't share a common format for strings, numbers, or errors. If you pass a JavaScript string directly to Rust, Rust sees garbage bytes. If Rust returns a pointer to stack memory, JavaScript will segfault when it tries to read it.
Node.js provides a low-level bridge called Node-API. It's a C-style API that lets native code interact with the JS engine safely. Writing raw Node-API is painful. You manage C pointers, handle errors manually, and write platform-specific build scripts for Windows, macOS, and Linux.
Crates like napi-rs and Neon wrap Node-API in safe Rust abstractions. They let you write idiomatic Rust functions and mark them for export. The crate generates the glue code that translates types, handles memory, and maps errors. napi-rs is the current community favorite. It builds faster, generates TypeScript definitions automatically, and has better async support. Neon is older and stable but has fallen behind in ecosystem momentum. This guide focuses on napi-rs.
Minimal example
Start with a fresh Rust project. You need the napi crate and the napi-build tool.
cargo init --lib my-rust-lib
cd my-rust-lib
cargo add napi --features=serde
cargo add napi-derive
Add a build.rs file to tell napi how to compile the binary.
// build.rs
fn main() {
napi_build::setup();
}
Now write the function. The #[napi] attribute does the heavy lifting.
// src/lib.rs
use napi::bindgen_prelude::*;
/// Adds two integers and returns the sum.
///
/// This function is exported to JavaScript as `add`.
#[napi]
pub fn add(a: i32, b: i32) -> i32 {
// WHY: #[napi] generates the C bindings and JS wrapper automatically.
// The types i32 map directly to JavaScript numbers.
a + b
}
Build the module.
npx napi build --platform --release
The command produces a .node binary and a JavaScript wrapper. Import it in Node.js.
// index.js
const { add } = require('./index.node');
console.log(add(1, 2)); // 3
Run it with node index.js. The output is 3. You just called Rust from JavaScript.
Trust the macro. If #[napi] compiles, the binding is likely safe. The macro prevents you from returning references to stack data or leaking memory across the boundary.
How the binding works
When you run napi build, the toolchain performs three steps.
First, it compiles your Rust code to a native binary. The output is a .dll on Windows, .so on Linux, or .dylib on macOS. This binary contains your Rust code plus the Node-API glue generated by the macro.
Second, it generates a JavaScript file that exports your functions. This file handles the type conversion. When you call add(1, 2) in JS, the wrapper converts the arguments to C types, calls the native function, converts the result back, and returns it.
Third, it places both files in a directory ready for require. The --platform flag builds for your current OS and architecture. In production, you'd build for all target platforms and distribute the binaries.
At runtime, Node.js loads the binary into memory. The JavaScript wrapper registers the functions with the Node-API. When you invoke a function, the call crosses the FFI boundary. Rust runs your code. The result crosses back. The entire process is fast, but it's not free. Crossing the boundary has overhead. Keep calls coarse-grained. Pass large chunks of data, not thousands of tiny values.
Realistic patterns
Real applications do more than add numbers. They process buffers, handle async work, and manage errors.
Buffers and data
JavaScript Buffer objects map directly to Rust Vec<u8> or napi::Buffer. This allows zero-copy data transfer in many cases.
use napi::bindgen_prelude::*;
/// Reverses the bytes in a buffer.
///
/// Takes a JavaScript Buffer and returns a new Buffer with reversed bytes.
#[napi]
pub fn reverse_buffer(input: Buffer) -> Result<Buffer> {
// WHY: Buffer implements AsRef<[u8]>, so we can slice it safely.
let data = input.as_ref();
// WHY: We must allocate a new buffer because Rust ownership rules
// prevent returning a slice of the input that outlives the call.
let mut reversed = data.to_vec();
reversed.reverse();
// WHY: Buffer::from creates a new JS Buffer from the Vec.
Ok(Buffer::from(reversed))
}
Convention aside: napi-rs supports Buffer and Vec<u8> interchangeably in function signatures. The crate handles the conversion. Prefer Buffer in the signature if the JS side expects a Buffer. It makes the intent clear.
Async functions
Node.js is single-threaded. Blocking the event loop kills your server. If your Rust function takes more than a few milliseconds, make it async.
use napi::bindgen_prelude::*;
/// Performs a heavy computation without blocking the event loop.
///
/// This function runs on a thread pool and returns a Promise.
#[napi]
pub async fn heavy_computation(input: String) -> Result<String> {
// WHY: #[napi] on an async fn automatically schedules the work
// on a background thread and returns a JavaScript Promise.
// Simulate work
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
Ok(format!("Processed: {}", input))
}
The #[napi] macro detects the async keyword. It generates code that spawns the function on a thread pool, waits for completion, and resolves a Promise in JavaScript. You call it with await heavyComputation("data").
Async is not optional in Node.js. If your Rust function blocks, your server dies. Use #[napi] async for anything that takes time.
Error handling
Rust Result maps to JavaScript exceptions. This is one of the best features of napi-rs. You don't need to check error codes manually.
use napi::bindgen_prelude::*;
/// Divides two numbers. Throws if the divisor is zero.
#[napi]
pub fn divide(a: f64, b: f64) -> Result<f64> {
if b == 0.0 {
// WHY: Returning an Err automatically throws a JavaScript exception.
// The error message appears in the JS stack trace.
return Err(Error::new(Status::GenericFailure, "Division by zero".to_string()));
}
Ok(a / b)
}
In JavaScript, this throws a standard error. You catch it with try/catch. The error message includes the Rust string. This keeps error handling idiomatic on both sides.
Convention aside: Use napi::Error::new with specific Status codes when possible. Status::GenericFailure works for most cases, but Status::InvalidArg or Status::OutOfMemory provide more context. The community prefers descriptive messages over cryptic codes.
TypeScript definitions
napi-rs generates .d.ts files automatically. Run napi build and you get index.d.ts.
export function add(a: number, b: number): number;
export function reverseBuffer(input: Buffer): Buffer;
export function heavyComputation(input: string): Promise<string>;
export function divide(a: number, b: number): number;
This saves hours of manual typing. The types stay in sync with your Rust code. If you change a signature, the TypeScript definitions update on the next build.
Pitfalls and gotchas
Platform mismatch
You build on macOS and deploy to Linux. The binary fails to load. Node.js requires binaries compiled for the target OS and architecture.
Fix this with a CI matrix. Build for linux-x64, win32-x64, darwin-x64, and darwin-arm64. Distribute the binaries in a prebuilds directory. Use napi build --platform in CI to cross-compile.
Blocking the event loop
Sync functions run on the main thread. If your Rust code allocates memory, parses JSON, or hashes data, it blocks everything.
Profile your code. If a function takes more than 1ms, make it async. Use #[napi] async or wrap the call in spawn_blocking. The event loop is fragile. Protect it.
Type mismatches
napi-rs is strict. If you return i32 but JavaScript expects a string, the wrapper fails at runtime. You'll get a cryptic error or a segfault.
Test the bindings thoroughly. Write integration tests that call the functions from JavaScript. Check the types. The compiler catches Rust errors, but the FFI layer is where runtime crashes hide.
Memory leaks
Rust's ownership system prevents leaks in safe code. The FFI boundary is where leaks can sneak in. If you hold a reference to a JavaScript object in Rust, you must release it.
napi-rs handles most cases automatically. Buffer and String are copied or cloned as needed. If you use raw pointers or unsafe blocks, you're responsible for cleanup.
Treat the SAFETY comment as a proof. If you can't write it, you don't have one.
Choosing your tool
Use napi-rs when you need a native Node.js addon with minimal friction. It handles TypeScript generation, async, and buffers with a modern API. The build speed and ecosystem support make it the default choice for new projects.
Use Neon when you are maintaining a legacy codebase that already depends on it. The API is stable, but new features land in napi-rs first. Migrate to napi-rs when you have the bandwidth.
Use WebAssembly (WASM) when you need to run the same Rust code in both the browser and Node.js. WASM is portable but has higher overhead for small calls and lacks direct access to Node.js APIs. Pick WASM for portability; pick native addons for raw performance.
Use raw extern "C" functions when you are building a library for multiple languages, not just Node.js. You'll need a separate binding layer for JavaScript, which adds maintenance cost. Only do this if you have a strong reason to share the core library across runtimes.
Pick the tool that matches your deployment target. Native addons give speed; WASM gives portability. You can't have both for free.