DEV Community

Alex Georgiev
Alex Georgiev

Posted on AI-assisted

Node.js 26.9 turns node:ffi on by default at 37 nanoseconds a call

Node.js 26.9.0 came out on 16 September, and one line in the changelog changes what "just call a C library" means in Node: node:ffi, the module for calling native functions without writing a compiled addon, is now on by default. Up to 26.8.2 you needed --experimental-ffi to touch it. From 26.9.0 it just works, and the flag that used to turn it on now turns it off.

I downloaded 26.9.0, wrote a small C library, and spent the morning finding out what that switch actually costs and where it breaks.

The flip itself

With no flags at all:

$ node test_ffi_basic.mjs
(node:839) ExperimentalWarning: FFI is an experimental feature and might change at any time
suffix: so
strlen("hello world") = 11n
Enter fullscreen mode Exit fullscreen mode

It runs, with a warning. Passing the flag that used to be required now does nothing new; the module is already there. Passing --no-experimental-ffi is the only way to get the old behaviour back:

$ node --no-experimental-ffi test_ffi_basic.mjs
node:internal/modules/esm/translators:481
    throw new ERR_UNKNOWN_BUILTIN_MODULE(url);
Error [ERR_UNKNOWN_BUILTIN_MODULE]: No such built-in module: node:ffi
Enter fullscreen mode Exit fullscreen mode

So this is a real default change, not a doc update. Anything that imports node:ffi and used to fail on older 26.x installs without the flag will now silently succeed on 26.9.0, which is worth knowing if you have version pins in CI.

What a call costs

node:ffi exists as an alternative to two older options: writing an N-API addon in C, or shelling out to a compiled binary. I built the addon comparison, since it's the fairer fight — both are calling into native code from a running Node process.

I compiled a tiny shared library with add_i32(a, b), wrote an equivalent N-API addon, and timed five million calls to each, plus the same operation in plain JS as a baseline:

Path ns per call (three runs)
plain JS 2.2 – 3.0
N-API addon 34.1 – 35.8
node:ffi 37.5 – 38.1

FFI is about 7-8% slower than a compiled addon doing the identical add, and both are around fifteen times the cost of staying in JS for something this trivial. That gap is the price of marshalling arguments across the JS/native boundary on every call, and it's paid whether you write C or just declare a signature.

The number that matters here isn't "FFI is slow" — 37ns is nothing on its own — it's that FFI does not beat the thing it's meant to replace. If you already have a native addon, node:ffi is not a performance upgrade over it. Its case is convenience: no node-gyp, no compiler toolchain in the deploy image, no rebuild per Node ABI version.

Where it does win: moving data, not making calls

The add-two-numbers test is dominated by call overhead, not work. I reran it with a function that actually does something: summing ten million float64 values through a pointer.

Path time for 10M-element sum (three runs)
plain JS for loop 16.0 – 18.4 ms
node:ffi, pointer to Float64Array.buffer 13.8 – 14.0 ms

Here FFI is consistently faster, by 15-20%, because one call carries ten million elements instead of amortising fixed overhead over ten million calls. This is the actual shape FFI is good at: bulk buffer operations, not many small calls.

The opposite reading matters too. I ran a single call to a native fib(75) computed with a tight iterative loop in C, against the same loop written in JS:

js-fib(75):  0.10ms
ffi-fib(75): 0.09ms
Enter fullscreen mode Exit fullscreen mode

Effectively identical. V8's JIT compiles a simple loop like that about as fast as gcc -O2 does, so for a single moderate computation, crossing into native code buys nothing. FFI's fixed per-call cost only pays for itself when the call count or the data volume is large enough to swallow it. A one-off "let me just call out to C for this" is usually not that case.

What it refuses, and what it doesn't

The docs call node:ffi "unsafe" and warn that a wrong signature can crash the process. I tested four ways to get it wrong, and got three different outcomes.

Passing a plain number where a uint64 argument is expected is rejected outright:

TypeError: Argument 1 must be a uint64
    at throwFFIArgError (node:internal/ffi/fast-api:76:15)
Enter fullscreen mode Exit fullscreen mode

You have to pass a BigInt. A Number, even a small one like 10000000, is refused, not coerced.

Calling a two-argument function with one argument is also caught:

TypeError: Invalid argument count: expected 2, got 1
    at throwFFIArgCountError (node:internal/ffi/fast-api:82:3)
Enter fullscreen mode Exit fullscreen mode

Both of those are argument-shape checks the module does for you, and neither matches the "unsafe, can crash" framing on its own.

Declaring the wrong return type is where that framing starts to hold. My fib function returns int64. I declared it as returning int32 and called it:

fib(75) declared as int32 return: 1845853122
correct int64 value would be: 2111485077978050
Enter fullscreen mode Exit fullscreen mode

No error. No warning. Just a silently truncated wrong number, which is the kind of bug that survives code review because nothing about it looks wrong.

Then I gave sum_doubles a real pointer but the wrong length — a 10-element buffer, told the function it had ten million elements:

$ node test_badpointer.mjs
calling sum_doubles with a 10-element buffer but claiming length 10,000,000...
Segmentation fault (core dumped)
Enter fullscreen mode Exit fullscreen mode

Exit code 139. That's the actual crash the documentation warns about, and it took nothing exotic to produce — just an inconsistent length, the sort of thing a refactor introduces when a buffer changes size but the call site doesn't get updated.

So the module validates argument count and basic scalar types, but not pointer bounds or return-type correctness. That's a meaningful line to know where it sits.

Permission Model and the closed-handle case

Node's Permission Model treats FFI as its own gate. Running under --permission without explicitly allowing it:

$ node --permission --allow-fs-read='*' test_ffi_basic.mjs
Error [ERR_ACCESS_DENIED]: Access to this API has been restricted. Use --allow-ffi to manage permissions.
Enter fullscreen mode Exit fullscreen mode

Adding --allow-ffi gets it working, with its own warning attached:

(node:1132) [PERM0003] SecurityWarning: The flag --allow-ffi must be used with extreme caution. It could invalidate the permission model.
Enter fullscreen mode Exit fullscreen mode

That's worth reading literally: turning FFI on inside a permission-restricted process is documented as capable of undermining the rest of the restrictions you set up, since native code isn't bound by any of them once it's loaded.

Closing a library handle and using it afterward is, by contrast, handled cleanly:

closed. calling again...
error after close: Error - Library is closed
Enter fullscreen mode Exit fullscreen mode

No crash there, and the newer using block syntax closed the handle automatically at the end of its scope, without an explicit .close() call, exactly as documented.

Concurrency

FFI calls work from worker threads with no special setup. I ran the same native fib(30) loop from 1, 2 and 4 workers on this box's 4 vCPUs:

Workers Calls Wall time Throughput
1 300,000 ~42 ms ~7.1M calls/s
2 600,000 ~36 ms ~16.6M calls/s
4 1,200,000 ~52 ms ~23M calls/s

Throughput scales with worker count rather than serialising through some global lock, which is what you'd want, though the single-worker number is depressed by that worker's own startup cost, so the 1→2 jump looks better than the underlying per-call rate actually improved.

What I got wrong on the way

My first attempt at the bad-pointer test reported success. I'd piped the script's output through grep to strip the experimental-feature warning, and checked $? afterwards to see if the process had crashed. It hadn't looked like it had — no crash message reached my terminal, exit code 0.

That exit code was grep's, not Node's. The pipe swallows the real exit status of the first command unless you go and check it explicitly. Once I ran the script without piping it through anything, the segfault was immediate and obvious: Segmentation fault (core dumped), exit 139. The lesson wasn't about FFI, it was about not trusting $? after a pipe.

Run it yourself

This needs Node 26.9.0 and a C compiler.

curl -sL -o node.tar.xz https://nodejs.org/dist/v26.9.0/node-v26.9.0-linux-x64.tar.xz
tar xf node.tar.xz
NODE=./node-v26.9.0-linux-x64/bin/node

cat > mylib.c << 'EOF'
#include <stdint.h>
int32_t add_i32(int32_t a, int32_t b) { return a + b; }
int64_t fib(int32_t n) {
    if (n < 2) return n;
    int64_t a = 0, b = 1;
    for (int32_t i = 2; i <= n; i++) { int64_t c = a + b; a = b; b = c; }
    return b;
}
EOF
gcc -shared -fPIC -O2 -o mylib.so mylib.c

cat > bench.mjs << 'EOF'
import { dlopen } from 'node:ffi';
const { functions } = dlopen('./mylib.so', {
  add_i32: { arguments: ['int32', 'int32'], return: 'int32' },
});
const N = 5_000_000;
for (let i = 0; i < 100000; i++) functions.add_i32(i, 1); // warm up
const start = process.hrtime.bigint();
let acc = 0;
for (let i = 0; i < N; i++) acc += functions.add_i32(i, 1);
const ms = Number(process.hrtime.bigint() - start) / 1e6;
console.log(`${(ms * 1e6 / N).toFixed(2)}ns/call, checksum=${acc}`);
EOF
$NODE bench.mjs
Enter fullscreen mode Exit fullscreen mode

Run on its own, that script prints something in the low-to-mid 40s of nanoseconds per call, not the 37-38ns in the table above. The table's numbers came from a script that ran the JS and N-API versions immediately before the FFI one, in the same process, so V8 had already warmed up its JIT machinery by the time FFI ran. Call order changed the result by about 15%, which is its own small lesson about trusting a single microbenchmark script. Change mylib.c to whatever you actually want to call and the absolute number will move either way.

What to do with this

My own conclusion, given what broke and what didn't: I wouldn't reach for node:ffi to shave time off a native addon that already works. The 37ns-versus-35ns gap in the table is the wrong direction to justify a rewrite, and the fib(75) result showed that a single native call doesn't even beat V8's own JIT once the workload is small. Where it earned its place in my tests was the ten-million-element sum, where one call carried the whole payload across the boundary instead of paying the marshalling cost ten million times.

Before any of this goes near production code, run something like my bad-pointer test above against the actual buffer sizes your code passes, not a made-up one. A uint64 length that comes from a miscounted array or user-controlled input is exactly the kind of value that turned a working sum_doubles call into Segmentation fault (core dumped) here, and node:ffi gave no warning on the way there.

Top comments (1)

Collapse
 
devopsdaily profile image
DevOps Daily

Still running on v24, probably a good time to upgrade