Replacing a Native Node Addon with Wasm
This page answers one task: a Node package depends on a native addon — compiled with node-gyp or shipped as per-platform prebuilt binaries — and you want to replace it with one WebAssembly module that installs and runs everywhere, while knowing exactly what that costs in speed.
Prerequisites
- [ ] An existing native addon with C, C++ or Rust sources and a JavaScript API on top.
- [ ] Emscripten, wasi-sdk or a Rust Wasm toolchain.
- [ ] A benchmark of the addon’s important operations, to compare against.
Why teams make the switch
Native addons are fast, but their distribution is painful. node-gyp needs Python and a C++ compiler on the user’s machine and fails in minimal Docker images, on Windows without build tools, and on new Node versions until the addon is rebuilt. Prebuilt binaries avoid compilation but multiply the build matrix: operating system × architecture × libc × Node ABI version, often dozens of artefacts per release, each of which can be missing for some user’s platform. And native code runs with full process privileges, so a memory-safety bug can corrupt the whole process.
A WebAssembly build replaces all of that with one file. The same .wasm runs on every platform and every Node version that supports WebAssembly, needs
no compiler at install time, works in Deno, Bun and browsers too, and is memory-isolated from the rest of the process. The price is performance — usually
a modest slowdown for compute, sometimes more for code that depends on native SIMD, threads or system calls — and some engineering to replace whatever
the addon did through Node’s native APIs.
Step 1 — inventory what the addon does
List every function the addon exposes and classify it: pure computation (parsing, hashing, compression, image processing), I/O (files, sockets), threading (background work via libuv thread pool), or integration with Node internals (Buffers, streams, callbacks). Pure computation moves to Wasm almost unchanged. I/O must move to JavaScript — the Wasm module receives bytes and returns bytes, and JavaScript does the reading and writing — or go through WASI for file access. Threading needs rethinking: Wasm threads require worker threads and shared memory, so it is often simpler to run the Wasm call in a worker thread and keep the module single-threaded.
Step 2 — compile the core to Wasm
For C/C++, build the computational core with Emscripten or wasi-sdk, exporting a flat C API. For Rust, build the crate for wasm32-unknown-unknown with
wasm-bindgen, or keep N-API bindings for the native build behind a feature flag and add wasm-bindgen bindings for the Wasm build:
emcc src/core/*.c -O3 -flto -o dist/core.mjs \
-sMODULARIZE -sEXPORT_ES6 -sENVIRONMENT=node,web \
-sALLOW_MEMORY_GROWTH -sEXPORTED_FUNCTIONS=_lib_compress,_lib_decompress,_malloc,_free \
-msimd128
-msimd128 enables WebAssembly SIMD, which recovers much of the speed of hand-vectorised native code when the compiler can map it. Remove code paths that
only exist for platform-specific intrinsics, or port them to Wasm SIMD intrinsics, as in
writing SIMD from C with wasm_simd128.h.
Step 3 — reproduce the JavaScript API exactly
Users should not notice the switch. Write a JavaScript wrapper with the same function names, argument types and return types as the addon — Buffer in,
Buffer out where the addon used Buffers — and the same errors:
import createModule from "./core.mjs";
const m = await createModule();
export function compress(input, level = 6) {
if (!Buffer.isBuffer(input)) throw new TypeError("input must be a Buffer");
const inPtr = m._malloc(input.length);
const cap = input.length + 1024;
const outPtr = m._malloc(cap);
try {
m.HEAPU8.set(input, inPtr);
const n = m._lib_compress(inPtr, input.length, outPtr, cap, level);
if (n < 0) throw new Error(`compress failed (${n})`);
return Buffer.from(m.HEAPU8.subarray(outPtr, outPtr + n)); // copy out into a Node Buffer
} finally {
m._free(inPtr); m._free(outPtr);
}
}
Async functions that the addon ran on the libuv thread pool can be reproduced by running the Wasm call in a worker_threads worker, preserving both the
API and the non-blocking behaviour. Run the addon’s existing test suite against the wrapper; it is the best compatibility check available, and any test that fails points directly at a
behaviour the wrapper does not yet reproduce.
Step 4 — measure the cost honestly
Benchmark the important operations with realistic inputs, native versus Wasm, on the platforms users run — x86-64 and arm64 at least. Expect pure computation to run at roughly 1.1–2× the native time; SIMD-heavy code without a Wasm SIMD port can be slower; code dominated by system calls will depend on how the I/O was restructured. Include the copies into and out of linear memory, which the native addon may not have needed, and the one-time compile cost at startup. The methodology is in measuring Wasm vs JavaScript throughput.
Step 5 — ship it, optionally keeping a native fast path
Publish the Wasm build as the default. If some users genuinely need the native speed, keep the native addon as an optional dependency that the wrapper tries to load first, falling back to Wasm when it is missing or fails:
let impl;
try { impl = (await import("my-lib-native")).default; } // optionalDependencies: installs where prebuilt exists
catch { impl = await import("./wasm-impl.js"); }
export const { compress, decompress } = impl;
This keeps installs reliable — the Wasm path always works — while letting platforms with prebuilt binaries keep native performance. Many popular packages, including image and compression libraries, follow exactly this pattern.
What tends to go wrong
A few problems recur in these migrations. Memory limits: a native addon can allocate as much as the process allows, while a 32-bit Wasm module is limited
to 4 GiB of linear memory and starts small, so code that processes very large inputs needs memory growth enabled, a sensible maximum, and ideally a
streaming API. Global state: addons sometimes keep caches or handles in static variables shared across calls; in Wasm these live in one instance, which
is fine, but if the wrapper creates several instances — for example one per worker thread — the caches multiply. Error behaviour: native code that
crashed the process on bad input now traps instead, which the wrapper must catch and translate into a thrown error, re-instantiating the module if its
state may be inconsistent. And locale or time functions: C code calling localtime or reading environment variables needs those provided through WASI or
replaced with explicit parameters. Testing with the original suite catches most of these; fuzzing the new wrapper with random inputs catches the rest.
Expected output
npm install of the new version succeeds on Linux, macOS, Windows and Alpine without compilers; the original test suite passes; compression throughput is
1.3× slower than the native addon on x86-64 and 1.2× on arm64; and the published package contains one .wasm instead of 24 prebuilt binaries.
Gotchas
- I/O inside the native code. Wasm cannot open files or sockets without WASI. Move I/O to JavaScript.
- libuv thread-pool async APIs. Reproduce them with worker threads, or the API becomes blocking.
- Ignoring the copy cost. Buffers must be copied into linear memory. Include that in benchmarks.
- Memory limits. Enable growth and set a maximum; stream very large inputs.
- Several instances multiplying caches. One instance per worker duplicates static state. Size caches with that in mind.
- Traps instead of crashes. Catch
RuntimeErrorin the wrapper and recover the instance.
Performance note
For a compression library on a 50 MB corpus, the native addon ran at 410 MB/s on x86-64; the Wasm build without SIMD ran at 250 MB/s, and with -msimd128
at 320 MB/s. The package size dropped from 24 prebuilt binaries totalling 31 MB to one 280 KB .wasm.
Frequently Asked Questions
Can Wasm call Node-API functions? No. Node-API is a native ABI. The JavaScript wrapper takes its place.
Will startup be slower? Slightly — the module compiles on first load, usually tens of milliseconds. Native addons load faster.
Does this work with Electron? Yes — Electron includes V8’s WebAssembly support, and avoiding native rebuilds for each Electron version is a major benefit.
What about GPU or OS-specific features? Those cannot move to Wasm. Keep them native, or move them to JavaScript APIs that provide them.
How do I keep the native and Wasm builds in sync? Build both from the same sources in one CI pipeline, and run the same test suite against each implementation behind the shared wrapper.
Should the Wasm build be a separate package? Usually not. Make it the main package and the native build the optional extra, so the default install always works.
Related
- Targeting Node and browsers from one Wasm package — publishing the result.
- Loading Wasm in Node.js with ES modules — the loader.
- Using Emscripten ports for common libraries — dependencies such as zlib.
- Recovering a module after a trap — replacing crashes with recoverable errors.
← Back to Wasm in Node.js, Deno & Bun