JS vs N-API vs WASM: what's actually faster in Node.js?
Node.js gives you three ways to run a computation: plain JavaScript, a native addon through N-API, or a WebAssembly module. Ask around and you'll hear the same advice — "if it's performance-critical, go native." But is native actually faster?
"Native is faster" is folk wisdom, and folk wisdom doesn't survive contact with a profiler. So I implemented the same five kernels three ways — plain JavaScript, N-API in pure C, and WebAssembly (AssemblyScript) — and measured them properly. The algorithm in each kernel is identical across all three implementations (same recursion, same hash constants, same arithmetic), every implementation is asserted to return identical results before a single timing counts, all inputs are deterministic and byte-identical, and each number below is the median of 15 runs after 3 warmup runs, with a forced GC before every measurement.
Every workload runs at three input sizes — small, medium, large — because the size axis is the story: small inputs measure call overhead, large inputs measure real compute.
All benchmarks ran on an Apple M4, Node v25.9.0. The full harness, raw JSON results, and chart generator are in the repo linked at the bottom — clone it and run npm run bench on your own machine.
One design decision matters more than any other: boundary costs are included on purpose. JS pays Buffer.from, N-API pays napi_get_value_string_utf8, WASM pays the copy into linear memory. Excluding those would benchmark a fantasy — crossing the boundary is precisely what you do in real code.
To be precise about where each contestant's code runs: the JS column is plain JavaScript; the N-API column is a single call into the C addon, which does all of its own marshalling and math in C; the WASM column calls kernels that run entirely inside the compiled module — for numeric inputs (both fib kernels) the wrapper is a pure passthrough — the entire computation runs inside WASM. For strings, objects, and buffers the WASM wrapper must first write the data into linear memory (WASM cannot see the JS heap); that glue is reduced to direct writes with zero intermediate copies, and it is deliberately inside the timed region.
Round 1 — Numbers: recursive Fibonacci
Pure compute, zero data transfer: one integer in, one number out, exponential recursion in between. Naive recursive fib(n) makes 2·fib(n+1)−1 calls, so fib(38) forces ~126 million real function calls — at the measured speed that's under 2 ns per call. (This is also why you'll never benchmark recursive fib(300): it would need ~10⁶² calls — around 10⁴⁶ years. Exponential algorithms are a feature here, not a bug: they turn a tiny input into an enormous, purely-computational load.)
| Input | JavaScript | N-API (C) | WASM |
|---|---|---|---|
| fib(20) | 40.3 µs | 38.8 µs | 19.6 µs |
| fib(30) | 5.5 ms | 4.8 ms | 2.4 ms |
| fib(38) | 252 ms | 222 ms | 111 ms |
WASM wins pure compute at every size — 2.3× faster than JavaScript at fib(38). The ranking is stable from 40 µs to 252 ms, which tells you it reflects the runtimes, not noise. Two details worth staring at:
- The
-O3C addon is only ~14% faster than JavaScript. Let that sink in: V8's JIT compiles this hot recursive function to machine code that nearly matches optimized C. The days of "JS is 10× slower than C" are long gone for hot numeric code. - WASM beats even C. Calls into a
.nodeaddon cross a JIT↔native frontier, while WASM executes inside V8's own sandbox where internal calls are extremely cheap — and 126 million calls amplify exactly that difference.
And fib(70)? Different algorithm, different story
Recursion can't reach fib(70) — that's ~6×10¹⁴ calls, days of compute — and there's a second wall: fib(79) already exceeds 2⁵³, so float64 can't even hold larger Fibonacci numbers exactly. (An actual 70-digit Fibonacci number is fib(≈335), which needs arbitrary-precision integers — at that point you'd be benchmarking V8's BigInt against a C bignum library like GMP, a different contest entirely.)
So for fib(70) the suite switches to the iterative algorithm with exact modular integer arithmetic (mod 1e9+7) — and because one iterative fib(70) is only ~70 additions (nanoseconds), the kernel chains N repetitions where each round's input depends on the previous result, so the optimizer can't hoist the work out of the loop:
function fib70(reps) {
let x = 0
for (let r = 0; r < reps; r++) {
x = fibIterMod(70 + (x % 5)) // data dependency: no hoisting possible
}
return x
}| Input | JavaScript | N-API (C) | WASM |
|---|---|---|---|
| 10k reps | 1.9 ms | 1.9 ms | 2.0 ms |
| 200k reps | 38.9 ms | 38.5 ms | 39.4 ms |
| 2M reps | 399 ms | 390 ms | 388 ms |
A three-way tie, at every size. This is the most instructive chart in the post when read next to the recursive one: the recursive benchmark stresses function-call machinery (where WASM wins 2×), while the iterative one stresses pure ALU loops — and on a tight integer add/mod loop, V8's JIT emits machine code that matches -O3 C exactly. When someone says "JavaScript is slow at math," this chart is the rebuttal.
Round 2 — Strings: FNV-1a hash
Hash the UTF-8 bytes of a string. Every implementation pays its own string→bytes conversion — that's the honest cost of receiving a JS string.
| Input | JavaScript | N-API (C) | WASM |
|---|---|---|---|
| 1 KB | 16.4 µs | 1.7 µs | 6.1 µs |
| 1 MB | 1.1 ms | 1.1 ms | 1.1 ms |
| 64 MB | 74.7 ms | 73.4 ms | 75.3 ms |
From 1 MB up it's a dead heat — all three within ~3%, and it stays a tie all the way to 64 MB. Once the string is big, this workload is bound by UTF-8 conversion and memory bandwidth, and everyone pays the same tax. There is no prize for native here.
The 1 KB result is the outlier worth understanding: N-API is ~10× faster than JS — because the JS version's Buffer.from(str) allocation dominates at small sizes, while napi_get_value_string_utf8 copies with less ceremony. Small-input results are about overheads, not compute.
Round 3 — Objects: the boundary massacre
Sum the distances between consecutive {x, y} points. This is JS-shaped data — and the chart says everything:
| Input | JavaScript | N-API (objects) | N-API (typed array) | WASM |
|---|---|---|---|---|
| 1k points | 4.2 µs | 179 µs | 6.0 µs | 7.5 µs |
| 100k points | 110 µs | 11.5 ms | 205 µs | 187 µs |
| 2M points | 3.3 ms | 259 ms | 5.3 ms | 5.9 ms |
Plain JavaScript beats naive N-API by roughly 100× at 100k points and 78× at 2 million. Not percent — times.
Why so brutal? The C code must fetch every value through the boundary: napi_get_element, two napi_get_named_property, two napi_get_value_double — five N-API calls per point, ten million calls at the 2M size, each one a function call into V8 internals with type checks and handle bookkeeping. Meanwhile the JS loop reads points[i].x through an inline cache in about a nanosecond, because V8 has specialized the property access for the object shape.
The measurements expose the collateral damage too: the 2M-point N-API objects run allocated +61 MB of JS heap (every element/property call materializes handles) and burned 293 ms of CPU for 259 ms of wall time — V8's garbage collector was cleaning up handle churn on parallel threads while the boundary ground away.
The two rightmost columns show the pragmatic middle path — change the data shape, keep the native code. Both flatten the objects into a Float64Array in JS first; WASM then copies it into linear memory, while N-API reads the typed array's backing store zero-copy via napi_get_typedarray_info. Same C kernel as the naive version, ~49× faster — the only thing that changed is what crossed the boundary. And still ~1.6× behind the plain JavaScript loop, because the flatten pass costs more than the entire JS computation.
The lesson: objects never cross the boundary cheaply. If your data is JS-shaped, the fastest native code is no native code.
Round 4 — Buffers: N-API's home turf
Sum all bytes of a Buffer.
| Input | JavaScript | N-API (C) | WASM |
|---|---|---|---|
| 4 KB | 19.3 µs | 3.0 µs | 10.9 µs |
| 4 MB | 3.4 ms | 2.8 ms | 3.5 ms |
| 256 MB | 216 ms | 171 ms | 192 ms |
N-API wins at every size — 1.3× over JavaScript at 256 MB — and the reason is architectural: napi_get_buffer_info hands C a pointer to the Buffer's actual bytes — zero copies, then a tight -O3 loop. WASM must first copy the entire payload into its linear memory (and at 256 MB, hold a second copy of the data there); JavaScript's indexed Buffer access keeps bounds checks the JIT can't fully eliminate on this pattern.
This is exactly why binary-heavy libraries — compression, crypto, my storj-uplink-nodejs — are N-API's sweet spot: big contiguous bytes, zero-copy in, real work inside.
CPU and memory: what the other two metrics said
- CPU ≈ wall time for the compute-heavy kernels — fib, hashing, and buffer sums are single-threaded, so
process.cpuUsage()trackedhrtimewithin noise. - The object round is where CPU and wall diverge. N-API (objects) at 2M points: 293 ms CPU vs 259 ms wall. JS at 2M points: 10.3 ms CPU vs 3.3 ms wall. In both cases the extra CPU is V8's parallel GC dealing with 2M heap objects — but N-API adds +61 MB of handle garbage per call on top (measured heap delta), while JS and WASM allocated ≈0.
- The structural memory story is copies. WASM duplicates every payload into linear memory — the 256 MB buffer run holds half a gigabyte of that data in RSS. N-API's Buffer path adds zero bytes. JS adds zero. For memory-constrained services processing large payloads, that difference matters more than any speed number above.
Verdict
| Workload | Winner | Why |
|---|---|---|
| Call-heavy compute (recursive fib) | WASM (2.3× JS) | No boundary data, cheap internal calls |
| Loop-heavy compute (iterative fib(70)) | three-way tie | V8's JIT matches -O3 C on ALU loops |
| Strings (hash) | tie from 1 MB up; N-API for small | Everyone pays the UTF-8 conversion tax |
| Objects | JavaScript (78–100× over naive N-API) | Inline caches vs 5 boundary calls per item |
| Buffers | N-API (1.3× JS at 256 MB) | Zero-copy pointer + -O3 loop |
Rules of thumb I'm taking away:
- V8 is fast. A JIT-compiled hot loop is within ~14% of
-O3C on recursive code and ties it outright on iterative integer loops. "Rewrite it in C" is not a plan; measure first. - The boundary is the price of admission. Native pays off only when compute-per-byte-crossed is high. Hashing strings is a wash at every realistic size; summing 256 MB zero-copy is a win.
- Never ship JS-shaped data to native code — change the shape instead. Flattening the same objects to a
Float64Arraytook the same N-API kernel from 259 ms to 5.3 ms (zero-copy vianapi_get_typedarray_info). And still check whether the flatten pass just made plain JS the winner anyway — here it did. - Pick WASM for portable pure compute, N-API for zero-copy Buffers and system access. They're not competitors; they're different tools.
- Benchmark on your workload, your data sizes, your machine. The size axis flipped several verdicts above — the repo below reruns the whole suite with one command.
Full source — kernels, harness, raw results, and chart generator: github.com/shivamkumar99/js-napi-wasm-benchmark