Skip to content

Build1 publisher3 min readPublished

Node.js 26.9.0 loads node:ffi for imports that used to need a flag

Calling C from JavaScript no longer needs --experimental-ffi on Node.js 26.9.0, though the module still prints an experimental warning on every run, and one developer's benchmarks measure each call against a compiled addon.

The Engineer · Build desk

Illustration accompanying Node.js 26.9.0 loads node:ffi for imports that used to need a flag

What happened

  • Node.js 26.9.0 came out on 16 September with node:ffi on by default; up to 26.8.2, touching the module required starting the process with --experimental-ffi.
  • On a bulk operation, summing ten million float64 values through a single pointer, FFI ran consistently 15-20% faster than the addon.
  • A function whose C return type is int64, declared in JavaScript as int32, returned a truncated value with no error and no warning, while two argument-shape mistakes were both caught as TypeErrors.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • exposure A CI pin that admits 26.9.0 no longer refuses an import of node:ffi, so code that reaches for native symbols will load on runners where it previously threw. Reasserting the old behaviour means adding --no-experimental-ffi to the runner command.
  • cost A team already shipping an N-API addon buys no speed by switching, so the ledger is on the build side: node-gyp goes away, the compiler leaves the deploy image, and the per-ABI rebuild stops.
  • constraint Declared return widths now need a test that exercises a value past 2^31. Code review cannot see a truncation whose result is a positive integer in the range you were expecting.
  • precedent A module that is on by default and still warns that it might change at any time sets up the next problem: unflagged code depending on an API whose maintainers have reserved the right to move it.

The switch is at the command line. On 26.9.0, passing --experimental-ffi adds nothing, and --no-experimental-ffi is the only way to get the old behaviour back [2]. The error it produces is ERR_UNKNOWN_BUILTIN_MODULE, thrown from node:internal/modules/esm/translators:481 [3]. So the flag governs whether node:ffi exists as a builtin at all, and the failure lands at import. The experimental warning still fires on an unflagged run, which is a generous reading of the word default [4].

Thirty-seven nanoseconds is one signature on one machine. The author compiled a shared library with add_i32(a, b), wrote an equivalent N-API addon, and timed five million calls to each plus the same add in plain JavaScript [7], and puts the FFI call at about 37ns [8]. Both TypeErrors in the write-up are raised from node:internal/ffi/fast-api, at lines 76 and 82 [13][14]. Those checks run in JavaScript, before the crossing, so they sit inside whatever a call is measured to cost. A signature taking uint64 will not behave like add_i32: a Number is refused, not coerced, and 10000000 has to arrive as a BigInt [13]. The first test printed suffix: so, so the run used a .so [16].

Two more numbers fall out of the percentages. At 7-8% behind, the addon does the same add in about 34ns, from 37 / 1.075 [17]. At fifteen times the JavaScript-only cost, plain JS does it in about 2.5ns, from 37 / 15 [18]. The penalty for declaring a signature instead of compiling an addon is therefore about 2.6ns a call [19].

fib(75) shows the same accounting from the other end. A tight iterative C loop reached through FFI ran 0.09ms against 0.10ms for the same loop in JavaScript, and the post credits V8's JIT with compiling a loop like that about as fast as gcc -O2 does [11]. 0.09ms is 90,000ns, and a 37ns crossing is roughly 0.04% of that [20].

I would want the bulk result repeated before I leaned on it. The post has FFI consistently 15-20% faster summing ten million float64 values through a pointer, and explains it as one call carrying ten million elements instead of fixed overhead amortised over ten million calls [10]. That explains one large call against many small ones. It does not explain FFI beating an N-API addon on the same single call, since the addon crosses the boundary once too.

The docs call node:ffi "unsafe" and warn that a wrong signature can crash the process [12]. The failure the author actually hit is quieter than a crash. His fib returns int64; declared as int32, the call returned 1845853122, where the correct value is 2111485077978050, with no error and no warning [15]. That truncated value is exactly 2111485077978050 mod 2^32 [21]. It also sits below 2^31, so it comes back as a positive integer of unremarkable size [22].

What to watch

  • Whether a later 26.x drops the ExperimentalWarning, which would tell you how stable the signature API is for code already running unflagged.
  • A second run of the ten-million-element sum that isolates why FFI beat the N-API addon on the same single call, and on a dylib or DLL rather than a .so.
  • Whether Node adds any validation of a declared return width against the loaded symbol, since the wrong width currently returns silently.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories