Skip to main content

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.

note

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."

WorkloadGood 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.
Measure first

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 an index.d.ts for you, @napi-rs/cli (napi build) drives the build, and it offers first-class cross-compilation with no node-gyp in 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 .wasm bytes are architecture-neutral, so one compiled module runs on every OS/arch with no build matrix. The wasm-pack glue is target-specific, though: --target nodejs emits 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:wasi capabilities you grant define the real trust boundary, so treat untrusted compute with care.
  • Shared code β€” the same .wasm runs 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.

DimensionNative addon (napi-rs)WebAssemblySidecar
Per-call overheadLowestLow–medium (copy tax)Highest (IPC)
Data crossingZero-copy Buffers, strings copyMostly copied (views can avoid it)Serialized
PortabilityBuild matrix per targetSingle .wasm, target-specific glueContainer per platform
SandboxingNone β€” full privilegesMemory-safe, not a full trust boundaryProcess isolation only
Crash isolationNone β€” shares processPartial β€” memory-safe traps, shares processFull β€” separate process
Build/CI complexityRust toolchain + prebuild matrixRust + wasm-pack, single targetRust build + deploy pipeline
Best forCoarse-grained hot kernelsPortable, memory-safe computeCoarse 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.

package layout
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:

Cargo.toml
[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"
build.rs
fn main() {
napi_build::setup();
}

On the npm side, add @napi-rs/cli and expose the build through a script, then install it:

package.json
{
"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:

src/lib.rs
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:

usage
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.

Blocking the event loop

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.

Platformnpm optional depNode-API loads
darwin-arm64@scope/pkg-darwin-arm64pkg.darwin-arm64.node
darwin-x64@scope/pkg-darwin-x64pkg.darwin-x64.node
linux-x64-gnu@scope/pkg-linux-x64-gnupkg.linux-x64-gnu.node
linux-arm64-gnu@scope/pkg-linux-arm64-gnupkg.linux-arm64-gnu.node
win32-x64-msvc@scope/pkg-win32-x64-msvcpkg.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.

Supply-chain surface

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 ThreadsafeFunction or Channel to call JS from native threads safely.
  • Memory management across the boundary β€” finalizers and External tie 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 optionalDependencies plus 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.

ToolWhat it doesIntegration
SWC (@swc/core)JS/TS compilernapi-rs native addon
RspackBundlerNative addon
Turbopack (Vercel/Next.js)BundlerNative addon
ParcelRust CSS transformer/resolverNative addon
lightningcssCSS transformer (used by Tailwind v4 / Parcel)napi-rs
BiomeFormatter/linterStandalone Rust binary (CLI)
PrismaQuery engineSidecar and Node-API library engine
@node-rs/* (bcrypt, argon2, jieba, crc32, xxhash)Crypto/text kernelsnapi-rs
note

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:wasi internals 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​

External resources​