Node.js with Rust for CPU-Bound Work
Keep your business and glue logic in Node/TypeScript, and delegate only the small CPU-bound hot kernel to Rust. Three integration approaches connect the two runtimes: an in-process native addon, a WebAssembly module, and an out-of-process sidecar. This cookbook is a decision guide for choosing between them plus a concrete napi-rs recipe for the most common case.
This is a decision guide and reference recipe, not a benchmarked production scaffold. Profile first β add a second toolchain only after profiling proves a genuine CPU-bound hot path. A Rust rewrite of code that is actually I/O-bound or glue logic adds cost and delivers nothing.
When to Use This β and When Not Toβ
Rust offload pays off only for CPU-bound hot paths. It adds a second toolchain and a build matrix, so the default is "keep it in TypeScript."
| Workload | Good fit for Rust offload? | Why |
|---|---|---|
| Parsing / AST (JS/CSS/TS compilers) | β | Tight, allocation-heavy tree walking dominates CPU |
| Compression | β | Bulk byte crunching over large buffers |
| Crypto / hashing | β | Fixed CPU-bound kernels with big inputs |
| Image / video processing | β | Pixel-level loops that saturate a core |
| Numeric / scientific compute | β | Dense math benefits from native codegen and SIMD |
| Tokenization / regex-heavy text | β | Character-by-character scanning is CPU-bound |
| Large-payload (de)serialization | β | Coarse-grained calls over big buffers amortize crossing cost |
| I/O-bound work | β | Node's async I/O is already efficient β nothing to speed up |
| Small payloads / chatty tiny calls | β | FFI and marshaling overhead dominates the actual work |
| Glue / business logic | β | Keep it in TypeScript β clarity beats micro-optimization |
- Reach for Rust when: profiling shows a CPU-bound kernel dominating wall-clock time; the kernel is coarse-grained (few calls, big buffers); the same hot path recurs across requests.
- Stay in TypeScript when: the bottleneck is I/O; calls are tiny and frequent; the code is business logic; a pure-JS
worker_threads/Piscina pool already clears the budget.
Profile before you add Rust. Use Node's --prof flag, perf_hooks, or clinic-style profiling to find where wall-clock time actually goes. Offload only the small hot kernel the profiler points at β not the surrounding code. See Performance and Node.js.
Three Integration Approachesβ
The choice comes down to where the Rust runs relative to the Node process: in the same process, in an in-process sandbox, or in a separate process.
A. Native addon via Node-API (napi-rs / Neon)β
A native addon is an in-process .node binary loaded over the stable Node-API C ABI. Node-API is ABI-stable across Node major versions, so a compiled addon keeps working on new Node releases without a recompile.
- napi-rs is the modern, recommended binding. Its
#[napi]proc-macros generate the boilerplate and anindex.d.tsfor you,@napi-rs/cli(napi build) drives the build, and it offers first-class cross-compilation with nonode-gypin the loop. - Neon is the older option. It also targets Node-API, but exposes a runtime API instead of macros, so you write more boilerplate by hand, get no automatic TypeScript types, and work with less community momentum.
Native addons are the fastest per call β zero-copy for Buffers and typed arrays β but they run with full process privileges and no sandbox.
B. WebAssembly (Rust β Wasm)β
Here Rust is compiled to a .wasm module and run through the WebAssembly API or node:wasi. wasm-bindgen generates the JSβWasm glue, and wasm-pack build --target nodejs packages it as an npm module.
Prefer Wasm when:
- Portability β the
.wasmbytes are architecture-neutral, so one compiled module runs on every OS/arch with no build matrix. Thewasm-packglue is target-specific, though:--target nodejsemits Node-flavored JavaScript, so reusing the module in the browser or an edge runtime means generating the matching target. - Sandboxing β core Wasm is memory-safe with no ambient authority, a useful containment primitive. It is not a turnkey security boundary on its own: the generated glue and any
node:wasicapabilities you grant define the real trust boundary, so treat untrusted compute with care. - Shared code β the same
.wasmruns in the browser and on the server (each with its own target glue).
Tradeoffs: Wasm has its own linear memory, so data usually crosses the boundary by copying β though a JavaScript TypedArray view over the module's exported memory can read results in place without a copy; Wasm threads and SIMD are less mature than native; and it is typically somewhat slower for CPU-bound work β measure your own workload.
C. Out-of-process sidecar (separate binary / service)β
The sidecar runs Rust as its own process, reached over stdio, a Unix socket, HTTP, or gRPC.
It beats an in-process integration when:
- Crash isolation β a panic or segfault in Rust must not take down Node.
- Lifecycle β a long-running, reused service with its own scaling/deploy cadence, or one owned by a separate team.
- Packaging β you would rather ship a container than an npm native-build matrix.
- Runtime independence β the service evolves on its own schedule.
Costs: serialization plus IPC/network latency on every call (much higher than in-process FFI), added operational complexity, and no shared memory. It fits coarse-grained, batchy calls and is a poor fit for chatty, tiny ones.
| Dimension | Native addon (napi-rs) | WebAssembly | Sidecar |
|---|---|---|---|
| Per-call overhead | Lowest | Lowβmedium (copy tax) | Highest (IPC) |
| Data crossing | Zero-copy Buffers, strings copy | Mostly copied (views can avoid it) | Serialized |
| Portability | Build matrix per target | Single .wasm, target-specific glue | Container per platform |
| Sandboxing | None β full privileges | Memory-safe, not a full trust boundary | Process isolation only |
| Crash isolation | None β shares process | Partial β memory-safe traps, shares process | Full β separate process |
| Build/CI complexity | Rust toolchain + prebuild matrix | Rust + wasm-pack, single target | Rust build + deploy pipeline |
| Best for | Coarse-grained hot kernels | Portable, memory-safe compute | Coarse batchy or independently-scaled work |
Recipe: A napi-rs Native Addonβ
A concrete, minimal walkthrough for the most common in-process case.
Project layoutβ
A napi-rs package pairs a Rust crate with an npm package. Cargo.toml and src/lib.rs hold the Rust side; build.rs runs napi-build at compile time; package.json describes the npm package and wires up @napi-rs/cli; napi build generates an index.js loader and an index.d.ts type declaration; and per-platform prebuilt binaries live in their own npm/ package directories.
The fastest way to a correct layout is the official scaffold β npm create napi@latest (or npx @napi-rs/cli new) β which generates all of the manifests below. Run it first, then replace the generated kernel with your own.
my-addon/
βββ Cargo.toml
βββ build.rs
βββ package.json
βββ src/
β βββ lib.rs
βββ index.js # generated loader (picks the right .node)
βββ index.d.ts # generated TypeScript types
βββ npm/
βββ darwin-arm64/
βββ linux-x64-gnu/
βββ win32-x64-msvc/
The package can live inside an npm workspace monorepo alongside the TypeScript code that consumes it.
Manifests and build scriptβ
If you set the crate up manually instead of scaffolding it, the Rust crate must build a cdylib, depend on napi/napi-derive, and run napi-build from build.rs:
[package]
name = "my-addon"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
napi = { version = "2", default-features = false, features = ["napi8"] }
napi-derive = "2"
[build-dependencies]
napi-build = "2"
fn main() {
napi_build::setup();
}
On the npm side, add @napi-rs/cli and expose the build through a script, then install it:
{
"name": "my-addon",
"version": "0.1.0",
"main": "index.js",
"types": "index.d.ts",
"napi": { "name": "my-addon" },
"scripts": {
"build": "napi build --platform --release"
},
"devDependencies": {
"@napi-rs/cli": "^2"
}
}
npm install
A Rust function exposed to Nodeβ
Expose a single CPU-bound kernel. This checksum reads a Buffer as a zero-copy &[u8] and returns a number:
use napi::bindgen_prelude::*;
use napi_derive::napi;
#[napi]
pub fn checksum(data: Buffer) -> u32 {
let bytes: &[u8] = data.as_ref();
let mut hash: u32 = 2166136261;
for &b in bytes {
hash ^= b as u32;
hash = hash.wrapping_mul(16777619);
}
hash
}
Building and consuming itβ
Build a release binary for the host platform, or pass a target triple to cross-compile. Use --platform so napi-rs also emits the index.js loader and index.d.ts consumed below:
# Build for the current platform
napi build --platform --release
# Cross-compile for a specific target triple
napi build --platform --release --target aarch64-unknown-linux-gnu
Import the generated function from TypeScript. The generated index.d.ts gives you types for free β no hand-written declarations:
import { checksum } from './index.js';
const digest = checksum(Buffer.from('hello world'));
console.log(digest); // fully typed: number
Async and calling back into JSβ
A synchronous native call blocks whichever thread runs it. To move work off that thread, napi-rs AsyncTask runs the computation on the libuv threadpool (four threads by default β tune with UV_THREADPOOL_SIZE). That pool is process-global and shared with Node's filesystem, crypto, DNS, and zlib work, so long CPU-bound tasks can starve those operations; when you need isolation, run the kernel on a dedicated worker_thread (or pool) instead of leaning on the shared pool. To call back into JavaScript from a Rust-owned thread, use ThreadsafeFunction or Channel, which marshal the call safely onto the JS thread.
A synchronous native or Wasm call blocks the thread it runs on. On the main thread that stalls the entire event loop β every pending request waits. Either make the call async (AsyncTask, which offloads to the libuv threadpool) or run it inside a worker_thread.
Packaging and Distributionβ
napi-rs ships one main package plus per-platform prebuilt binaries published as npm optionalDependencies β for example @scope/pkg-darwin-arm64, @scope/pkg-linux-x64-gnu, and @scope/pkg-win32-x64-msvc. Each optional dependency is guarded by os and cpu fields so npm skips packages for the wrong platform. Those two fields cannot tell glibc from musl, though: when both Linux variants are published, napi-rs also emits libc metadata and its loader detects the runtime ABI at load time. Whether the resolver installs only the single matching package (rather than every same-os/cpu candidate) depends on package-manager support for the libc field. A small JS loader picks the correct .node at runtime. The result: consumers need no Rust toolchain and no node-gyp at install time.
| Platform | npm optional dep | Node-API loads |
|---|---|---|
| darwin-arm64 | @scope/pkg-darwin-arm64 | pkg.darwin-arm64.node |
| darwin-x64 | @scope/pkg-darwin-x64 | pkg.darwin-x64.node |
| linux-x64-gnu | @scope/pkg-linux-x64-gnu | pkg.linux-x64-gnu.node |
| linux-arm64-gnu | @scope/pkg-linux-arm64-gnu | pkg.linux-arm64-gnu.node |
| win32-x64-msvc | @scope/pkg-win32-x64-msvc | pkg.win32-x64-msvc.node |
The exact list depends on your support matrix. napi-rs generates a GitHub Actions build matrix that cross-compiles every target triple in CI β see GitHub Actions supply chain for pinning and securing that pipeline.
Native addons run with full process privileges and no sandbox, so the prebuilt platform packages resolved as optionalDependencies widen your audit surface β pin and review them like any other dependency. That optional-dependency model resolves a matching package normally and does not itself run a postinstall download. The ignore-scripts guidance targets a different, higher-risk pattern: packages whose lifecycle scripts fetch or build a binary at install time. Audit both, but keep them distinct. See npm supply-chain attacks and npm.
worker_threads: Where the Work Runsβ
worker_threads decides where work runs (off the main thread); Rust/Wasm decides how fast the work is. They compose.
node:worker_threads runs JS or native code on a separate OS thread with its own event loop and V8 isolate. Threads communicate via message passing, SharedArrayBuffer, and Transferable objects. Even with Rust offload, a synchronous native or Wasm call blocks whichever thread executes it β so either make the call async or run it in a worker.
The pure-JS alternative is worth remembering: a worker_threads pool such as Piscina may clear the budget for moderately heavy JavaScript with no Rust at all β which reinforces "measure first."
See Node.js and Performance.
Complexities and Costsβ
Adopting Rust adds real, recurring costs. Enumerate them up front for honest scoping:
- FFI boundary & serialization overhead β a fixed cost on every call. Prefer coarse-grained APIs that pass big buffers over chatty, fine-grained ones.
- Data marshaling / copying β napi-rs is zero-copy for
Buffers and typed arrays, but strings and objects are copied; Wasm always copies across its linear memory. - Blocking the event loop β synchronous compute stalls the thread it runs on. Make it async or run it in a worker.
- Thread safety β use
ThreadsafeFunctionorChannelto call JS from native threads safely. - Memory management across the boundary β finalizers and
Externaltie Rust allocations to JS object lifetimes; get it wrong and you risk leaks or use-after-free. - Build toolchain complexity β a Rust toolchain is required in both development and CI. napi-rs at least keeps you
node-gyp-free. - Cross-platform prebuild & CI matrix β you must build every target triple you support.
- npm packaging β a main package plus per-platform
optionalDependenciesplus a JS loader. - Cross-language debugging β you juggle the JS debugger and
lldb/gdb; stack traces cross the boundary and there are no source maps spanning both languages. - Versioning / ABI β choose the Node-API version your minimum supported Node provides; it is ABI-stable across Node majors. Wasm is portable with no per-Node ABI concern.
- Security β native addons run unsandboxed with full process privileges; Wasm and WASI are sandboxed.
Effort Considerationsβ
The recurring effort centers are standing up the Rust plus CI cross-build matrix once, maintaining the packaging and runtime loader, and paying the ongoing cross-language debugging tax. The first-call scope should be the smallest hot kernel the profiler identifies β not a wholesale port. Everything around that kernel stays in TypeScript, where it is cheaper to change. If you need a full engagement estimate with a task breakdown, use the project recipes format rather than reaching for numbers here β this page deliberately does not fabricate dev-day epics.
Proven in Productionβ
These tools use Rust-in-JS at scale, which is good evidence the pattern is mature.
| Tool | What it does | Integration |
|---|---|---|
SWC (@swc/core) | JS/TS compiler | napi-rs native addon |
| Rspack | Bundler | Native addon |
| Turbopack (Vercel/Next.js) | Bundler | Native addon |
| Parcel | Rust CSS transformer/resolver | Native addon |
| lightningcss | CSS transformer (used by Tailwind v4 / Parcel) | napi-rs |
| Biome | Formatter/linter | Standalone Rust binary (CLI) |
| Prisma | Query engine | Sidecar and Node-API library engine |
@node-rs/* (bcrypt, argon2, jieba, crc32, xxhash) | Crypto/text kernels | napi-rs |
Do not frame Yarn or pnpm as "Rust rewrites." The strong, verifiable examples of Rust-in-JS are SWC, Rspack, Turbopack, Biome, and Prisma.
Exclusionsβ
- No benchmark numbers or runnable production scaffold.
- No full dev-day estimation breakdown β see the project recipes for that format.
- No deep dive into
node:wasiinternals or specific Rust crate APIs. - No per-workload native-vs-Wasm performance figures β they are workload-dependent, so measure your own case.
Further Readingβ
Aliz docsβ
- Node.js
- Performance
- npm Workspaces
- npm
- npm supply-chain attacks
- GitHub Actions supply chain
- Choosing a Web 3D Architecture
External resourcesβ
- napi-rs docs β https://napi.rs/
- Node-API reference β https://nodejs.org/api/n-api.html
- @node-rs packages β https://github.com/napi-rs/node-rs
- Neon β https://neon-rs.dev/
- wasm-bindgen β https://rustwasm.github.io/docs/wasm-bindgen/
- wasm-pack β https://rustwasm.github.io/docs/wasm-pack/
- node:worker_threads β https://nodejs.org/api/worker_threads.html
- node:wasi β https://nodejs.org/api/wasi.html
- Piscina β https://github.com/piscinajs/piscina
- SWC β https://swc.rs/
- Prisma engines β https://www.prisma.io/docs/orm/more/under-the-hood/engines