[{"data":1,"prerenderedAt":6},["ShallowReactive",2],{"post-content-node-ffi-call-c-libraries-without-addon":3},{"content":4,"lastModified":5},"\u003Cp>Reading the Node 26.9.0 changelog from September 16, I got to one line and stopped. \u003Ccode>node:ffi\u003C\u002Fcode> is enabled by default. The built-in for calling functions out of a shared library straight from JavaScript, with no compiled addon, no \u003Ccode>node-gyp\u003C\u002Fcode>, and no C toolchain in the deploy image.\u003C\u002Fp>\n\n\u003Cp>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 \u003Ccode>python3\u003C\u002Fcode> symlink on the CI box, prebuilds that did not exist for the Node ABI we had just moved to, and a \u003Ccode>.node\u003C\u002Fcode> artifact that worked on my laptop and died in the container.\u003C\u002Fp>\n\n\u003Cp>A flipped flag is genuinely good news. It is also the kind of news where you hunt for the catches first.\u003C\u002Fp>\n\n\u003Cimg src=\"https:\u002F\u002Fimages.unsplash.com\u002Fphoto-1518770660439-4636190af475?w=1200&amp;q=80\" alt=\"Close-up of a dark circuit board with a surface-mounted chip and capacitors\" loading=\"lazy\" \u002F>\n\n\u003Ch2>What actually changed in 26.9.0\u003C\u002Fh2>\n\n\u003Cp>The module shipped back in 26.1.0, behind \u003Ccode>--experimental-ffi\u003C\u002Fcode>. In 26.9.0 the flag flipped. Passing it now does nothing, and the only route back to the old behavior is \u003Ccode>--no-experimental-ffi\u003C\u002Fcode>, under which the import fails with \u003Ccode>ERR_UNKNOWN_BUILTIN_MODULE\u003C\u002Fcode>.\u003C\u002Fp>\n\n\u003Cp>The part that matters for CI is which direction the failure moved. Code importing \u003Ccode>node:ffi\u003C\u002Fcode> 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.\u003C\u002Fp>\n\n\u003Cp>The docs still mark it \u003Ccode>Stability: 1 - Experimental\u003C\u002Fcode>, and it still prints an ExperimentalWarning on startup. Under the permission model it needs \u003Ccode>--allow-ffi\u003C\u002Fcode>.\u003C\u002Fp>\n\n\u003Ch2>The benchmarks are not the pitch\u003C\u002Fh2>\n\n\u003Cp>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.\u003C\u002Fp>\n\n\u003Cp>For a trivial \u003Ccode>add_i32\u003C\u002Fcode> 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 \u003Ccode>node:ffi\u003C\u002Fcode> 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.\u003C\u002Fp>\n\n\u003Cp>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 \u003Ccode>fib(75)\u003C\u002Fcode> came out at 0.09 ms in C and 0.10 ms in JS, which is a tie.\u003C\u002Fp>\n\n\u003Cp>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.\u003C\u002Fp>\n\n\u003Ch2>A binding, end to end\u003C\u002Fh2>\n\n\u003Cp>The API is smaller than I expected. You load a library, declare a signature, call the symbol.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-c\">\u002F* stats.c *\u002F\ndouble sum_f64(const double *values, unsigned long long count) {\n  double total = 0.0;\n  for (unsigned long long i = 0; i &lt; count; i++) total += values[i];\n  return total;\n}\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cpre>\u003Ccode class=\"language-bash\"># Linux\ncc -shared -fPIC -O2 -o libstats.so stats.c\n\n# macOS\ncc -dynamiclib -O2 -o libstats.dylib stats.c\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>Then from JavaScript. The \u003Ccode>suffix\u003C\u002Fcode> export gives you \u003Ccode>so\u003C\u002Fcode>, \u003Ccode>dylib\u003C\u002Fcode> or \u003Ccode>dll\u003C\u002Fcode>, so the path needs no branching per platform.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-javascript\">const { DynamicLibrary, suffix } = require('node:ffi');\n\nconst lib = new DynamicLibrary(`.\u002Flibstats.${suffix}`);\nconst sum = lib.getFunction('sum_f64', {\n  arguments: ['pointer', 'uint64'],\n  return: 'double',\n});\n\nconst values = Float64Array.from({ length: 1e7 }, (_, i) =&gt; i * 0.5);\nconsole.log(sum(values, BigInt(values.length)));\n\nlib.close();\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>A typed array passed where the signature says \u003Ccode>pointer\u003C\u002Fcode> 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 \u003Ccode>ffi.dlopen(path, definitions)\u003C\u002Fcode> and you get a \u003Ccode>using\u003C\u002Fcode> binding instead, closing the handle on block exit.\u003C\u002Fp>\n\n\u003Cp>There is no struct support, so if you need one you assemble it by hand with \u003Ccode>ffi.getInt32(pointer, offset)\u003C\u002Fcode> and \u003Ccode>ffi.setInt32(pointer, offset, value)\u003C\u002Fcode>. 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.\u003C\u002Fp>\n\n\u003Ch2>Where it bites\u003C\u002Fh2>\n\n\u003Cp>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 \u003Ccode>uint64\u003C\u002Fcode> throws instead of coercing. But the write-up that produced those numbers declared an \u003Ccode>int64\u003C\u002Fcode> return as \u003Ccode>int32\u003C\u002Fcode> and got a silently truncated value. Handing a valid pointer an inflated length is worse: instant segfault, exit code 139.\u003C\u002Fp>\n\n\u003Cp>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 \u003Ca href=\"\u002Fsnippetark\u002F\">Snippet Ark\u003C\u002Fa> rather than retyping from memory.\u003C\u002Fp>\n\n\u003Cp>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.\u003C\u002Fp>\n\n\u003Cp>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.\u003C\u002Fp>\n\n\u003Cp>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 \u003Ca href=\"\u002Fposts\u002Ftypescript-7-native-compiler-what-changes\u002F\">TypeScript 7 compiling in Go\u003C\u002Fa> or \u003Ca href=\"\u002Fposts\u002Fbun-1-4-rust-rewrite-node-compatibility\u002F\">runtimes rewriting themselves in Rust\u003C\u002Fa>. Native code keeps getting closer to the language, and this time it arrived without a toolchain bolted to it.\u003C\u002Fp>\n","2026-09-20",1789991458758]