Native Add-ons, WebAssembly & FFI
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Node.js 26 (26.10.0, released 2026-09-21, the Current line that becomes LTS on 2026-10-28), with the differences
of Node.js 24 "Krypton" (24.21.0, Active LTS) and Node.js 22 "Jod" (22.23.3, Maintenance) called out; versions and stability levels were
checked against the official documentation on 2026-10-04. Node-API (Stability 2, Stable) is the supported way to ship compiled code to
Node.js users. WebAssembly (the V8 engine feature behind the WebAssembly global) is part of the language runtime, and ES module
integration for .wasm files is at release-candidate and active-development stability. node:wasi is Stability 1 (Experimental), and node:ffi,
new in v26.1.0, is Stability 1 (Experimental) and does not exist at all on Node.js 24 and 22. Every example below was run on
Node.js 26 and, where the text says so, on 24 and 22; the one C++ add-on, the C add-on and the shared library were compiled with the Apple
clang toolchain that ships with Xcode on macOS (the commands are the same on Linux, only the shared-library suffix and compiler differ).
Examples that were not executed are labelled as such.
Almost everything in a Node.js program is JavaScript, and most of the time that is the right place to stay: the engine compiles it to fast machine code, the standard library covers the common cases, and a pure-JavaScript package installs everywhere. Three situations push you out of that comfort zone: you need an operating-system or hardware facility that Node.js does not expose, you have an existing C, C or Rust library that is not worth rewriting, or you have a hot loop where measured numeric work in JavaScript is too slow. This page covers the three ways Node.js offers for those cases, in decreasing order of maturity: compiled add-ons through Node-API, WebAssembly (and WASI for programs that expect an operating system), and the new `node:ffi` module that calls functions in an existing shared library without compiling anything. It builds on xref:backend/nodejs/introduction-and-architecture.adoc[Introduction & Architecture], which shows where V8 and the C bindings sit under the JavaScript layer, and it complements Worker Threads and Child Processes, the pure-Node ways to use more CPU or to run another program. Its permission flags are covered in context in Permission Model.
The stability and version map
| API | Stability (Node.js 26 docs) | Version notes |
|---|---|---|
Node-API ( |
2 Stable |
|
|
Stable |
The portable way to load an add-on from either module system. |
|
Release candidate (1.2) in the CLI reference, Early development (1.0) in the add-ons page |
|
|
No stability index listed in the CLI reference |
Disables loading native add-ons and the |
|
1.1 Active development |
Added in v21.6.0 and v20.12.0. Without it, |
|
Part of the V8 engine and the JavaScript language runtime |
Available on every supported line; V8 12.4 on Node.js 22, 13.6 on 24, 14.6 on 26. |
ES module integration: |
1.1 Active development |
No flag since v24.5.0 and v22.19.0. Printed an |
|
1.2 Release candidate |
Added in v24.0.0; a syntax error on Node.js 22 (observed). |
|
1 Experimental |
Added in v13.3.0 and v12.16.0. |
|
1.1 Active development |
Added in v22.3.0 and v20.16.0. |
|
1 Experimental |
Added in v26.1.0; absent on 24 and 22 (observed |
|
1.1 Active development |
Added in v26.1.0. |
Choosing a route
The routes differ in what you must ship, what can go wrong, and who can call what. Read this table first, then the section for the route you are considering; the decision guidance at the end of the page refines it.
| Route | What you write and ship | Strength | Cost |
|---|---|---|---|
Node-API add-on |
C or C++ source, a build description ( |
Full access to the operating system and any native library; ABI-stable across Node.js majors |
A compiler toolchain at build time; one binary per operating system and CPU architecture; memory-safety bugs crash the whole process |
WebAssembly module |
A |
Runs anywhere Node.js and browsers run; code cannot touch memory outside its own linear memory |
Needs glue code to pass anything but numbers; no direct operating-system access; copying data across the boundary costs time |
WASI program |
A |
Portable command-line style programs with files, environment and standard streams |
Experimental in Node.js; the embedder grants capabilities, but Node.js does not promise sandboxing |
|
Nothing to compile on the Node.js side; an existing shared library and a signature description in JavaScript |
Quick access to a function in a library you already have |
Experimental, Node.js 26 only; a wrong signature or a stale pointer corrupts memory or crashes; no safety net |
Node-API add-ons
A native add-on is a dynamically linked library with the extension .node that Node.js loads at run time and that registers JavaScript
functions implemented in C or C. There are three generations of interface. Add-ons written directly against V8 and libuv headers
(documented in the C add-ons page) and add-ons using Native Abstractions for Node.js (NAN) depend on engine internals, so they must be
rebuilt for every Node.js major release, because the ABI of the engine changes with each one. Node-API (formerly N-API) is the
engine-independent interface maintained as part of Node.js itself; the documentation presents it as the ABI-stable interface, and the Learn
guide recommends its C++ wrapper node-addon-api for most projects because it reduces boilerplate while keeping ABI stability.
ABI stability in practice
An Application Binary Interface (ABI) is the compiled counterpart of an API: it fixes names, argument layouts and structure sizes at the
machine level. Node.js commits to a stable ABI across the minor and patch releases of one major, and Node-API extends that promise
across majors: an add-on compiled once against Node-API loads in later major versions without recompilation. The Learn guide spells out
why it matters: a package written only in JavaScript survives a new Node.js major untouched, while a package with a V8-based add-on has to
be recompiled, reinstalled and redeployed. The version matrix in the API documentation adds a refinement. Up to Node-API 8 each version
was a superset of the previous one. From version 9 on, an add-on that works with version 9 may need code changes for version 10, so
Node.js defaults to the version 8 interface unless the add-on opts in with the NAPI_VERSION macro, which is baked into the binary at
build time.
A first add-on in C
The smallest useful add-on for the Bookshelf application validates ISBN-10 check digits. Three files describe it: the C source, the
binding.gyp build description, and a package.json that marks the package as having a native build. Node-API needs only one header,
node_api.h, and no dependency from npm.
#include <node_api.h>
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
/* isbn10Valid(digits: string): boolean */
static napi_value Isbn10Valid(napi_env env, napi_callback_info info) {
size_t argc = 1;
napi_value argv[1];
if (napi_get_cb_info(env, info, &argc, argv, NULL, NULL) != napi_ok) return NULL;
if (argc < 1) {
napi_throw_type_error(env, "ERR_MISSING_ARG", "isbn10Valid(digits) needs a string");
return NULL;
}
char buf[16];
size_t len = 0;
if (napi_get_value_string_utf8(env, argv[0], buf, sizeof buf, &len) != napi_ok) {
napi_throw_type_error(env, "ERR_INVALID_ARG_TYPE", "digits must be a string");
return NULL;
}
bool ok = false;
if (len == 10) {
int sum = 0;
ok = true;
for (size_t i = 0; i < 10; i++) {
int v;
if (buf[i] >= '0' && buf[i] <= '9') v = buf[i] - '0';
else if (i == 9 && buf[i] == 'X') v = 10;
else { ok = false; break; }
sum += v * (int)(10 - i);
}
ok = ok && (sum % 11 == 0);
}
napi_value result;
napi_get_boolean(env, ok, &result);
return result;
}
NAPI_MODULE_INIT() {
napi_value fn;
if (napi_create_function(env, "isbn10Valid", NAPI_AUTO_LENGTH, Isbn10Valid, NULL, &fn) != napi_ok) return NULL;
if (napi_set_named_property(env, exports, "isbn10Valid", fn) != napi_ok) return NULL;
return exports;
}
Every Node-API call returns a napi_status, and the function must not continue as if a failed call had succeeded. The buffer is a
fixed 16 bytes and napi_get_value_string_utf8 truncates to its size, so an over-long input can never overflow it; reading a length
first with a null buffer is the pattern to use when the size is unknown. The example was compiled and run; it was not fuzzed, and it is a
teaching example, not a hardened parser.
{
"targets": [
{
"target_name": "isbn",
"sources": ["isbn.c"],
"defines": ["NAPI_VERSION=8"]
}
]
}
{
"name": "bookshelf-isbn",
"version": "1.0.0",
"private": true,
"main": "index.js",
"gypfile": true,
"scripts": { "build": "node-gyp rebuild" }
}
Source: Node-API build tools: node-gyp
Building with node-gyp or CMake.js
node-gyp is the default build tool: npm bundles it (version 12.4.0 inside npm 11.19.x on Node.js 26 and 24, version 11.5.0 inside
npm 10.9.9 on Node.js 22, as observed), and it drives the platform compiler (Xcode command line tools on macOS, a C and C toolchain on Linux,
Visual Studio Build Tools on Windows); the Learn guide's tools page lists a C/C compiler and Python as requirements of node-gyp.
If a package has a binding.gyp in its root and defines no install or
preinstall script, npm install runs node-gyp rebuild by itself. It first downloads the Node.js headers for the running version,
which is why the first build needs network access. The build below ran with Node.js 24.
npm install # runs node-gyp rebuild because binding.gyp exists
ls build/Release/*.node
# build/Release/isbn.node
CMake.js is the alternative for projects that already use CMake. It is based on the CMake tool, which must be installed, and the Learn guide lists its cons as being less widely adopted in the Node.js community. CMake was not installed in the environment used to write this page, so the following file was not executed; it adapts the Learn guide’s template to the same C source.
cmake_minimum_required(VERSION 3.9)
project(isbn)
add_definitions(-DNAPI_VERSION=8)
include_directories(${CMAKE_JS_INC})
add_library(${PROJECT_NAME} SHARED isbn.c ${CMAKE_JS_SRC})
set_target_properties(${PROJECT_NAME} PROPERTIES PREFIX "" SUFFIX ".node")
target_link_libraries(${PROJECT_NAME} ${CMAKE_JS_LIB})
Source: Node-API build tools: CMake.js
The package then declares "install": "cmake-js compile" and a development dependency on cmake-js. The third tool in the Learn guide,
node-pre-gyp, adds distribution of pre-built binaries (downloaded from an object store) on top of node-gyp, so users without a compiler
can still install the package. The Learn guide contrasts two strategies: compile on the user’s machine at install time, which is simple
but requires a working toolchain for every user, or publish pre-built binaries per platform and architecture and fall back to compiling.
A package with a native dependency tree is the usual reason an installation fails on a new machine or a new Node.js major, so the
second strategy is worth the effort for anything published; Workspaces & Publishing
covers what publishing adds for Node packages in general, and the Learn page on publishing Node-API packages explains how to publish a
Node-API version next to a legacy one with an npm dist-tag.
Loading an add-on
Node.js recognises the .node extension in the CommonJS loader, so require('./build/Release/isbn.node') initialises the library, and
the extension can usually be omitted. From an ES module, use module.createRequire(). The lowest-level entry point,
process.dlopen(module, filename), is what the loader calls and is occasionally useful when a path is computed.
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const { isbn10Valid } = require('./index.js');
console.log(process.version, 'abi', process.versions.modules);
console.log(isbn10Valid('0306406152')); // true (valid check digit)
console.log(isbn10Valid('0306406153')); // false
try {
isbn10Valid();
} catch (err) {
console.log(err.name, err.code, err.message);
}
index.js here is a single line: module.exports = require('./build/Release/isbn.node');.
v22.23.3 abi 127
true
false
TypeError ERR_MISSING_ARG isbn10Valid(digits) needs a string
v24.21.0 abi 137
true
false
TypeError ERR_MISSING_ARG isbn10Valid(digits) needs a string
v26.10.0 abi 147
true
false
TypeError ERR_MISSING_ARG isbn10Valid(digits) needs a string
Source: Learn: ABI Stability
process.versions.modules is the engine ABI number that changes with every major (127, 137, 147 here). A V8-based add-on built for one
of those numbers is refused by the others; the Node-API add-on above never cared, which is the whole point. As for import, the observed
behaviour on 26.10.0 and 24.21.0 was that import('./build/Release/isbn.node') resolved to a module whose default and
'module.exports' exports are the add-on’s exports, with an ExperimentalWarning; on 22.23.3 it needed --experimental-addon-modules.
Since the add-ons page, the CLI reference and the ES modules page disagree about the status, keep createRequire in anything that has to
run on all three lines.
The C++ wrapper and object wrapping
Writing every call against the C API is tedious and error-prone, so most projects use node-addon-api, a header-only C wrapper
published on npm (version 8.9.2 resolved when this page was written). It turns `napi_value` handles into `Napi::Value`, `Napi::String`
and friends, and offers `Napi::ObjectWrap` to expose a C class as a JavaScript class. It is still Node-API underneath, so the ABI
stability applies. NAPI_DISABLE_CPP_EXCEPTIONS below keeps the build exception-free, and errors are raised with
ThrowAsJavaScriptException().
#include <napi.h>
#include <cctype>
#include <string>
// slugify(title: string): string -> "Dune Messiah" => "dune-messiah"
Napi::Value Slugify(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
if (info.Length() < 1 || !info[0].IsString()) {
Napi::TypeError::New(env, "slugify(title) needs a string").ThrowAsJavaScriptException();
return env.Null();
}
std::string in = info[0].As<Napi::String>().Utf8Value();
std::string out;
bool pendingDash = false;
for (unsigned char c : in) {
if (std::isalnum(c)) {
if (pendingDash && !out.empty()) out.push_back('-');
pendingDash = false;
out.push_back(static_cast<char>(std::tolower(c)));
} else {
pendingDash = true;
}
}
return Napi::String::New(env, out);
}
Napi::Object Init(Napi::Env env, Napi::Object exports) {
exports.Set("slugify", Napi::Function::New(env, Slugify));
return exports;
}
NODE_API_MODULE(slug, Init)
{
"targets": [
{
"target_name": "slug",
"sources": ["slug.cc"],
"include_dirs": ["<!(node -p \"require('node-addon-api').include_dir\")"],
"defines": ["NAPI_VERSION=8", "NAPI_DISABLE_CPP_EXCEPTIONS"],
"cflags_cc": ["-std=c++17"],
"xcode_settings": { "CLANG_CXX_LANGUAGE_STANDARD": "c++17" }
}
]
}
With "dependencies": { "node-addon-api": "^8.0.0" } in package.json, npm install built the add-on on Node.js 26;
require('./').slugify('Dune Messiah!') returned dune-messiah there and on Node.js 22 with the same binary, and passing a number
raised TypeError: slugify(title) needs a string. The string is converted to UTF-8 and the loop only handles ASCII letters and digits, so
a title written in another script is reduced to dashes. Real code needs a proper transliteration library.
Two limits of the examples above deserve a mention because real add-ons hit them quickly. Both functions run synchronously on the JavaScript thread, so a slow native function blocks the event loop exactly as slow JavaScript would (see Don’t Block the Event Loop). Moving work to a thread and returning a promise or calling back from another thread are covered by the Node-API special topics in the Learn guide: asynchronous work, thread-safe functions, context awareness (an add-on may be loaded more than once, including inside worker threads) and object and function references.
Security and operations
A native add-on runs with all the privileges of the Node.js process and can corrupt its memory; nothing in Node.js sandboxes it. For that
reason two switches exist. --no-addons turns add-on loading off, and with the permission model, --permission refuses add-ons unless
--allow-addons is also passed. Observed on 26.10.0 with the add-on above: node --permission --allow-fs-read="$PWD" check.mjs failed with
Error: Cannot load native addon because loading addons is disabled., and adding --allow-addons worked but printed a SecurityWarning
that the flag must be used with extreme caution, because it can invalidate the permission model. node --no-addons check.mjs failed with
ERR_DLOPEN_DISABLED. See Security Best Practices for the supply-chain side: a
dependency that downloads or compiles binaries at install time deserves the same scrutiny as any lifecycle script.
WebAssembly
WebAssembly is a compact binary instruction format that V8 compiles to machine code; the Learn guide describes it as a
high-performance assembly-like language that can be compiled from C, C++, Rust and AssemblyScript. A .wasm file is a module;
instantiating it yields an instance with its own memory (a resizable ArrayBuffer) and optional tables. A module can only reach
what it imports and can only touch its own memory, which makes it a much safer unit of native code than an add-on, and the same file
runs in browsers and in Node.js. It cannot, on its own, read a file or open a socket: every capability comes from imported functions that
the host supplies.
Instantiating a module
The shortest possible example embeds a hand-assembled module (the bytes of (func (export "add") (param i32 i32) (result i32)
local.get 0 local.get 1 i32.add)) so that nothing has to be installed. WebAssembly.validate() checks bytes without compiling them,
WebAssembly.instantiate() compiles and instantiates, and exported functions appear under instance.exports.
// A hand-assembled module: (func (export "add") (param i32 i32) (result i32) local.get 0 local.get 1 i32.add)
const bytes = new Uint8Array([
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, // magic + version
0x01, 0x07, 0x01, 0x60, 0x02, 0x7f, 0x7f, 0x01, 0x7f, // type: (i32, i32) -> i32
0x03, 0x02, 0x01, 0x00, // one function of type 0
0x07, 0x07, 0x01, 0x03, 0x61, 0x64, 0x64, 0x00, 0x00, // export "add"
0x0a, 0x09, 0x01, 0x07, 0x00, 0x20, 0x00, 0x20, 0x01, 0x6a, 0x0b, // body
]);
console.log(WebAssembly.validate(bytes)); // true
const { instance } = await WebAssembly.instantiate(bytes);
console.log(instance.exports.add(5, 6)); // 11
console.log(instance.exports.add(2 ** 31 - 1, 1)); // -2147483648 (i32 wraps)
Source: Learn: Node.js with WebAssembly
The output (true, 11, -2147483648) was identical on Node.js 26, 24 and 22. The last line shows the boundary to keep in mind:
WebAssembly integers are fixed-width, so i32 arithmetic wraps where a JavaScript number would not. In practice nobody writes bytes by
hand; the Learn guide lists four ways to produce a module: writing the text format (.wat) and converting it with the wabt tools,
compiling C or C++ with Emscripten, compiling Rust with wasm-pack, or writing AssemblyScript, a TypeScript-like language.
Memory, imports and the glue layer
Numbers cross the boundary directly; anything else (strings, arrays, records) travels through the module’s linear memory, and the host
and the module must agree on the layout. The next module imports a JavaScript function (env.log), exports its memory, and sums a range
of bytes in it. It is written in the text format so that the logic is readable.
(module
(import "env" "log" (func $log (param i32)))
(memory (export "memory") 1)
;; sum(ptr, len): adds len bytes starting at ptr, reports the total to the host
(func (export "sum") (param $ptr i32) (param $len i32) (result i32)
(local $total i32)
(block $done
(loop $next
(br_if $done (i32.eqz (local.get $len)))
(local.set $total
(i32.add (local.get $total) (i32.load8_u (local.get $ptr))))
(local.set $ptr (i32.add (local.get $ptr) (i32.const 1)))
(local.set $len (i32.sub (local.get $len) (i32.const 1)))
(br $next)))
(call $log (local.get $total))
(local.get $total)))
Convert it with wat2wasm sum.wat -o sum.wasm (from the WebAssembly Binary Toolkit, wabt). In the environment used here the command line
tool was not installed, so the same conversion was done through the wabt package from npm (version 1.0.39) in a scratch project; the
resulting 112-byte file is what the next script loads.
import { readFile } from 'node:fs/promises';
const bytes = await readFile(new URL('./sum.wasm', import.meta.url));
const { instance } = await WebAssembly.instantiate(bytes, {
env: { log: (total) => console.log('wasm says total =', total) },
});
const { memory, sum } = instance.exports;
const view = new Uint8Array(memory.buffer, 0, 4); // a window onto linear memory
view.set([10, 20, 30, 40]);
console.log(sum(0, 4)); // 100
console.log(memory.buffer.byteLength); // 65536 (one 64 KiB page)
Source: Learn: Node.js with WebAssembly
The output was wasm says total = 100, 100, 65536 (observed on 26). A view created from memory.buffer is invalidated when the
module grows its memory, so re-create views after any call that might allocate. Tools such as Emscripten and wasm-pack generate this
glue (string encoding, allocation, error handling) for you, which is usually better than writing it by hand.
ES module integration
Node.js can also load .wasm files through the module system. Since v24.5.0 and v22.19.0 this no longer needs
--experimental-wasm-modules. A module with no imports of its own can be imported like any other (the instance phase, Stability 1.1),
and the exports become named exports. import source (the source phase, Stability 1.2 and added in v24.0.0) gives you the compiled
WebAssembly.Module so that you can create several instances or pass custom imports.
// esm-import.mjs: add.wasm has no imports
import { add } from './add.wasm';
console.log(add(40, 2)); // 42
// src-phase.mjs: get the compiled module and instantiate it yourself
import source sumModule from './sum.wasm';
const instance = await WebAssembly.instantiate(sumModule, {
env: { log: () => {} },
});
console.log(sumModule instanceof WebAssembly.Module, instance.exports.sum(0, 0));
Observed: esm-import.mjs printed 42 and an ExperimentalWarning ("Importing WebAssembly module instances is an experimental feature")
on Node.js 26, 24 and 22. src-phase.mjs printed true 0 on 26 and 24, and failed with a syntax error on 22. Two further rules from
the module documentation: module and export names that start with wasm-js: are reserved, and wasm: is reserved for export names and
imported module names (since v24.5.0 and v22.19.0). The wasm:js-string compile-time builtins mentioned in the same page are an advanced
feature that this page does not use.
WASI
A plain module has no way to print a line. The WebAssembly System Interface (WASI) standardises a set of POSIX-like system calls, such as
fd_write for writing to a file descriptor, that a module imports and a host implements. Node.js implements the wasi_snapshot_preview1
snapshot (and the older unstable one) in node:wasi. A program that exports start is a _command and is run with wasi.start(); a
module that exports initialize is a _reactor, a library initialised once with wasi.initialize() and then called through its other
exports. start() and initialize() also need an exported memory and may be called only once per WASI object.
wasi.finalizeBindings() (v24.4.0) sets the host bindings without running either entry point, for modules instantiated in worker
threads that share memory.
The module below is the hello-world from the official API page, converted with the same wabt step as before. The WASI constructor
requires version, and the guest only sees what the host passes: here an empty environment and no directories, instead of forwarding
process.env and a broad preopens mapping.
(module
(import "wasi_snapshot_preview1" "fd_write" (func $fd_write (param i32 i32 i32 i32) (result i32)))
(memory 1)
(export "memory" (memory 0))
(data (i32.const 8) "hello world\n")
(func $main (export "_start")
(i32.store (i32.const 0) (i32.const 8))
(i32.store (i32.const 4) (i32.const 12))
(call $fd_write
(i32.const 1)
(i32.const 0)
(i32.const 1)
(i32.const 20)
)
drop
)
)
import { readFile } from 'node:fs/promises';
import { WASI } from 'node:wasi';
const wasi = new WASI({
version: 'preview1',
args: ['demo'],
env: {}, // pass nothing: the guest sees an empty environment
preopens: {}, // and no directories
});
const wasm = await WebAssembly.compile(
await readFile(new URL('./demo.wasm', import.meta.url)),
);
const instance = await WebAssembly.instantiate(wasm, wasi.getImportObject());
const code = wasi.start(instance);
console.log('exit code', code);
On Node.js 26, 24 and 22 the output was the same: an ExperimentalWarning: WASI is an experimental feature and might change at any
time, then hello world and exit code 0. returnOnExit is true by default since v20.1.0, which is why start() returned the exit
code instead of ending the process. preopens maps a virtual directory that the guest sees to a real directory on the host, for example
{ '/data': './bookshelf-data' }, and is the way to give a command access to files.
Observe the security wording carefully. WASI offers a capabilities-based model (the guest receives only the environment, directories and
descriptors you pass), but the Node.js documentation states that the current threat model does not provide the secure sandboxing of some
WASI runtimes, that file-system sandboxing can be escaped by various techniques, and that you must not rely on node:wasi to run
untrusted code. With the permission model, creating a WASI instance is refused unless --allow-wasi is passed (Stability 1.1): observed
on 26, node --permission --allow-fs-read="$PWD" wasi.mjs failed with ERR_ACCESS_DENIED ("Use --allow-wasi to manage permissions"), and
adding --allow-wasi ran it and printed a SecurityWarning in addition. If you need to run code you do not trust, use a process or a
container boundary, and see Permission Model for what the permission flags do and do not
protect.
The node:ffi module
A foreign function interface (FFI) lets JavaScript call a function in an existing shared library without writing a binding. node:ffi,
added in v26.1.0, loads a dynamic library, resolves symbols by name and wraps them as JavaScript functions according to a signature that
you declare. It is Stability 1 (Experimental) and, observed, absent on Node.js 24 and 22 (ERR_UNKNOWN_BUILTIN_MODULE for
node:ffi). It is enabled by default on 26.10.0: no opt-in flag exists, and --no-experimental-ffi disables it (the same import then
fails with the same error code). It is only available in builds with FFI support (bundled libffi on supported platforms, or a shared
libffi), only under the node: scheme, and it is not supported by the unofficial GN build.
The module is unsafe by design. The documentation says that passing invalid pointers, using an incorrect signature, or accessing freed
memory can crash the process or corrupt memory. Nothing in the module tracks pointer validity or ownership. Treat each dlopen call like
a C function call written without a compiler: the signature is a claim that nobody checks.
Calling a function
The example uses a three-function library compiled from C. Its source is part of the example because the signatures must match it exactly.
cc -shared -fPIC -o mylib.dylib mylib.c (on Linux, name it mylib.so)#include <stdint.h>
#include <string.h>
int32_t add_i32(int32_t a, int32_t b) { return a + b; }
uint64_t string_length(const char *s) { return (uint64_t)strlen(s); }
/* calls back into JavaScript for each element */
int32_t map_sum(const int32_t *values, int32_t n, int32_t (*fn)(int32_t)) {
int32_t total = 0;
for (int32_t i = 0; i < n; i++) total += fn(values[i]);
return total;
}
import { dlopen, suffix } from 'node:ffi';
{
using handle = dlopen(new URL(`./mylib.${suffix}`, import.meta.url).pathname, {
add_i32: { arguments: ['int32', 'int32'], return: 'int32' },
string_length: { arguments: ['string'], return: 'uint64' },
});
console.log(handle.functions.add_i32(20, 22)); // 42
console.log(handle.functions.string_length('Dune')); // 4n (uint64 comes back as a bigint)
} // the library is closed here
Observed on 26.10.0: an ExperimentalWarning: FFI is an experimental feature and might change at any time, then 42 and 4n. The
using declaration works because the object returned by dlopen() implements the explicit resource management protocol and closes the
library when the block ends. ffi.suffix is 'dylib' on macOS, 'so' on Unix-like systems and 'dll' on Windows, which keeps the path
portable. Signature objects use type names such as int32, uint64, float64, pointer, string, buffer and arraybuffer
(with short aliases like i32, f64 and ptr), and 64-bit integers cross as bigint.
Callbacks, pointers and memory
library.registerCallback() creates a native function pointer backed by a JavaScript function, and library.getFunction() resolves one
symbol at a time. The restrictions are strict: a callback must be invoked on the thread that created it, must not throw, must not return
a promise, must return a value compatible with its declared type, and must not close its library or unregister itself while it runs.
Native code must stop using a callback address before the callback is unregistered or the library closed; calling a stale pointer is
undefined behaviour.
import { DynamicLibrary, suffix } from 'node:ffi';
const lib = new DynamicLibrary(new URL(`./mylib.${suffix}`, import.meta.url).pathname);
try {
const mapSum = lib.getFunction('map_sum', {
arguments: ['buffer', 'int32', 'pointer'],
return: 'int32',
});
// A native function pointer backed by a JavaScript function
const double = lib.registerCallback(
{ arguments: ['int32'], return: 'int32' },
(value) => value * 2,
);
const values = Buffer.from(new Int32Array([1, 2, 3, 4]).buffer);
console.log(mapSum(values, 4, double)); // 20
lib.unregisterCallback(double); // only after native code can no longer call it
} finally {
lib.close();
}
The output was 20 on 26.10.0. When a Buffer is passed as a pointer-like argument, Node.js borrows the raw pointer to its backing
memory for the duration of the call, so the buffer must not be resized, transferred or detached during that call, including from a
callback. The module also has memory helpers (ffi.toString(), ffi.toBuffer(), ffi.toArrayBuffer(), the ffi.export* functions and
ffi.getRawPointer()); the documentation advises preferring copied values unless zero-copy access is required. Passing null to
dlopen() resolves symbols from the running process, which is enough to call a libc function such as getpid:
import { dlopen } from 'node:ffi';
// null: resolve symbols from the running process (libc is already loaded)
const { functions } = dlopen(null, {
getpid: { arguments: [], return: 'int32' },
});
console.log(functions.getpid() === process.pid); // true
Source: FFI: ffi.dlopen(path[, definitions)]
null is not supported on Windows. Observed: it printed true on macOS with Node.js 26. FFI calls that fit a platform-specific
trampoline use an optimised Fast FFI path (at most 8 arguments, with register limits that differ per architecture; the documentation
tabulates them), and everything else falls back to a generic path, so benchmark your own signature before assuming a speed-up.
The permission model
With --permission, the FFI APIs are restricted unless --allow-ffi is passed (Stability 1.1, added in v26.1.0). Observed on 26.10.0:
node --permission --allow-fs-read="$PWD" call.mjs failed with ERR_ACCESS_DENIED ("Use --allow-ffi to manage permissions"), and adding
--allow-ffi printed a SecurityWarning about extreme caution plus the normal output. The warning is accurate: once a process may load
arbitrary libraries and call arbitrary symbols, it can do anything the user can do, so the permission model’s file and network limits no
longer constrain it.
When each route is worth it
Start from the problem and prefer the highest row that solves it.
| Situation | Prefer | Why |
|---|---|---|
The standard library, a worker thread or another process can do the job |
Stay in JavaScript, or see Worker Threads and Child Processes |
No toolchain, no per-platform binaries, no crash risk; I/O-bound work rarely benefits from native code anyway |
You must use an operating-system facility or a C or C++ library that Node.js does not expose |
Node-API, with a prebuilt-binary distribution strategy |
Full access, ABI stable across majors, supported by the project, and a binary per platform you can test in CI |
CPU-bound numeric or parsing work that must be portable and safe, or code that already exists as C, C++ or Rust and must also run in browsers |
WebAssembly |
One artifact for every platform, memory-safe boundary; measure the cost of copying data in and out, because small calls can be slower than plain JavaScript |
A command-line program compiled to WASI that you want to run from Node.js |
|
The |
A quick prototype or a script that calls a few functions in a library you already have installed |
|
Nothing to compile on the Node.js side, but experimental, unsafe and not available on 24 or 22 |
A concrete example is natural language processing with a native spell-checker package: add-ons of that kind, when written against V8 or
NAN rather than Node-API, have repeatedly failed to compile on a newer Node.js major because the engine headers changed. The remedies in
order of preference are to replace the package with a pure-JavaScript or Node-API alternative, to wrap the underlying C library yourself
through Node-API, or to run it as a separate process and talk to it over a pipe. Whatever you pick, write down which Node.js lines the
package supports in engines, run your test suite on each line in CI, and pin the toolchain versions used to build. A broken native
dependency should fail in CI, not on the day an LTS upgrade reaches production.
What changed recently
| Version | Change |
|---|---|
v26.10.0 |
|
v26.6.0 |
|
v26.5.0 |
The CLI reference says |
v26.1.0 |
|
v25.5.0 and v24.13.1 |
Node-API gained |
v24.5.0 and v22.19.0 |
|
v24.4.0 |
|
v24.0.0 |
WebAssembly source phase imports ( |
v23.6.0 and v22.20.0 |
|
v22.14.0 and v23.6.0 |
Node-API version 10 available. |
v22.3.0 and v20.16.0 |
|
v21.6.0 and v20.12.0 |
|
v20.1.0 and v20.0.0 |
|
Where the books are outdated
Neither book treats this area as a main topic, so the notes are short. Efficient Node.js by Samer Buna (O’Reilly, 2025) is written
against Node.js 22 and npm 10 and touches native add-ons only where it explains how Node.js finds and loads code. Since then the picture
has gained several pieces: the add-on you load should be a Node-API one so that the binary survives a new major, importing a .node file
from an ES module is now possible (a release-candidate feature enabled by default in recent lines, with createRequire still the portable
route), --no-addons and --allow-addons control loading, and WebAssembly and node:ffi are alternatives that avoid compiled add-ons
altogether. Node.js Projects by Jonathan Wexler (O’Reilly, 2025) uses a native spell-checking package as one ingredient of its
sentiment-analysis project; the lesson for today is to check that such a package supports your Node.js line, to prefer Node-API-based or
pure-JavaScript replacements, and to expect that a build can fail on a new major. The sections on WASI and node:ffi have no counterpart in
either book, so everything in them comes from the official documentation.
Pitfalls
-
Mixing build and run versions for V8-based add-ons. A binary compiled against one major is refused by another with an ABI number mismatch. Node-API removes the problem, but only if every native call in the add-on is a Node-API call and the
NAPI_VERSIONyou declared is supported by the oldest Node.js line you target. -
Not checking
napi_status. Continuing after a failed call produces undefined behaviour; return early and throw a JavaScript error. -
Blocking the event loop. A synchronous native call stalls every request; move long work to async work, a thread-safe function or a worker thread.
-
Copying costs. Crossing into WebAssembly or native code with large strings or arrays can cost more than the computation saves; batch calls and keep data on one side.
-
Stale views and pointers. A typed array over
memory.buffer, or a pointer obtained through FFI, can be invalidated by growth, detach or free; keep lifetimes short and explicit. -
Trusting
node:wasias a sandbox. It is not one; treat untrusted code as untrusted. -
Relying on
node:ffiin a portable program. It exists only on Node.js 26 and later, only in builds with FFI support, and its API is Experimental. -
--allow-addonsand--allow-ffiin production flags. Both can invalidate the permission model; add them only for the process that needs them.