4 min read

Calling C Libraries from Node.js Without an Addon (node:ffi)

Reading the Node 26.9.0 changelog from September 16, I got to one line and stopped. node:ffi is enabled by default. The built-in for calling functions out of a shared library straight from JavaScript, with no compiled addon, no node-gyp, and no C toolchain in the deploy image.

I have wanted this for years. The last time I needed it, the C code was forty lines and the build pipeline was three days. A missing python3 symlink on the CI box, prebuilds that did not exist for the Node ABI we had just moved to, and a .node artifact that worked on my laptop and died in the container.

A flipped flag is genuinely good news. It is also the kind of news where you hunt for the catches first.

Close-up of a dark circuit board with a surface-mounted chip and capacitors

What actually changed in 26.9.0

The module shipped back in 26.1.0, behind --experimental-ffi. In 26.9.0 the flag flipped. Passing it now does nothing, and the only route back to the old behavior is --no-experimental-ffi, under which the import fails with ERR_UNKNOWN_BUILTIN_MODULE.

The part that matters for CI is which direction the failure moved. Code importing node:ffi used to blow up loudly without the flag. Now it runs quietly on 26.9.0 and above, and not at all below it. Pin a Node range somewhere and that switch happens underneath you.

The docs still mark it Stability: 1 - Experimental, and it still prints an ExperimentalWarning on startup. Under the permission model it needs --allow-ffi.

The benchmarks are not the pitch

A careful set of numbers came out the same day, and the summary is uncomfortable: FFI does not beat the thing it is meant to replace.

For a trivial add_i32 across five million calls, plain JavaScript sat at roughly 2 to 3 nanoseconds per call, an equivalent N-API addon at 34 to 36, and node:ffi at 37 to 38. Both are around fifteen times the cost of staying in JavaScript, and that gap is argument marshalling. You pay it whether you wrote C or only declared a signature.

The win arrives in the opposite shape. Sum ten million float64 values through a pointer and FFI lands near 14 ms against 16 to 18 ms for a plain JS loop, because one call carries all ten million values instead of spreading fixed overhead over ten million calls. A lone fib(75) came out at 0.09 ms in C and 0.10 ms in JS, which is a tie.

Bulk buffer work, yes. A one-off hop into C to do something V8 already handles, no. And if you already ship a native addon, this is a convenience, not an upgrade.

A binding, end to end

The API is smaller than I expected. You load a library, declare a signature, call the symbol.

/* stats.c */
double sum_f64(const double *values, unsigned long long count) {
  double total = 0.0;
  for (unsigned long long i = 0; i < count; i++) total += values[i];
  return total;
}
# Linux
cc -shared -fPIC -O2 -o libstats.so stats.c

# macOS
cc -dynamiclib -O2 -o libstats.dylib stats.c

Then from JavaScript. The suffix export gives you so, dylib or dll, so the path needs no branching per platform.

const { DynamicLibrary, suffix } = require('node:ffi');

const lib = new DynamicLibrary(`./libstats.${suffix}`);
const sum = lib.getFunction('sum_f64', {
  arguments: ['pointer', 'uint64'],
  return: 'double',
});

const values = Float64Array.from({ length: 1e7 }, (_, i) => i * 0.5);
console.log(sum(values, BigInt(values.length)));

lib.close();

A typed array passed where the signature says pointer hands over its backing memory. That is the zero-copy path and also the footgun: Node borrows that memory only for the duration of the call, so resizing or transferring it while native code runs can corrupt memory. Swap the class for ffi.dlopen(path, definitions) and you get a using binding instead, closing the handle on block exit.

There is no struct support, so if you need one you assemble it by hand with ffi.getInt32(pointer, offset) and ffi.setInt32(pointer, offset, value). I keep the native surface to flat arrays and scalars and reshape in JS. There is also a hard ceiling on the fast call path: 6 integer or pointer arguments on x86-64 Linux, 4 of them if a buffer parameter is among them, and 3 total on Windows.

Where it bites

The docs are blunt that a wrong signature crashes the process, and the validation is partial. Argument count is checked, and a Number passed where the signature says uint64 throws instead of coercing. But the write-up that produced those numbers declared an int64 return as int32 and got a silently truncated value. Handing a valid pointer an inflated length is worse: instant segfault, exit code 139.

That second one is exactly the bug a refactor introduces, when a buffer changes size and one call site does not. Which makes the signature table the thing worth reviewing in a pull request, and the thing I keep next to the C header in Snippet Ark rather than retyping from memory.

Callbacks work, with edges. They have to run on the thread that registered them, must not throw, must not return a promise, and cannot unregister themselves mid-flight.

My plan is narrow on purpose. Binary file formats, plus one image pipeline where the buffer is large and the call count is small. Both are places where I used to shell out to a compiled binary. I will not swap out an addon that works, and I will not put this in a hot loop.

Forty lines of C with no build pipeline attached is still a trade I will take every time. It matches where the rest of the stack has been heading, whether that is TypeScript 7 compiling in Go or runtimes rewriting themselves in Rust. Native code keeps getting closer to the language, and this time it arrived without a toolchain bolted to it.