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

Table 1. APIs covered on this page
API Stability (Node.js 26 docs) Version notes

Node-API (node_api.h, napi_* functions, node-addon-api)

2 Stable

process.versions.napi is 10 on Node.js 26.10.0, 24.21.0 and 22.23.3 (observed). Node-API 10 needs v22.14.0 or later (v23.6.0 on the 23 line); Node-API 9 needs v18.17.0, v20.3.0 or v21.0.0 or later; the default version when NAPI_VERSION is not defined is 8.

require() of a .node file, process.dlopen(), module.createRequire()

Stable

The portable way to load an add-on from either module system.

import of a .node file

Release candidate (1.2) in the CLI reference, Early development (1.0) in the add-ons page

--experimental-addon-modules was added in v23.6.0 and v22.20.0. The CLI reference says it is enabled by default from v26.5.0. Observed: import() of a .node file worked without any flag on 26.10.0 and 24.21.0, printed an ExperimentalWarning, and failed with ERR_UNKNOWN_FILE_EXTENSION on 22.23.3 without the flag. Two official pages disagree, so use createRequire when the code must run everywhere.

--no-addons

No stability index listed in the CLI reference

Disables loading native add-ons and the node-addons exports condition; loading then throws ERR_DLOPEN_DISABLED (observed on 26).

--allow-addons (permission model)

1.1 Active development

Added in v21.6.0 and v20.12.0. Without it, --permission refuses to load add-ons.

WebAssembly global (compile, validate, instantiate, Module, Instance, Memory)

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: import { f } from './x.wasm' (instance phase)

1.1 Active development

No flag since v24.5.0 and v22.19.0. Printed an ExperimentalWarning on 26, 24 and 22 (observed).

import source and import.source() (source phase)

1.2 Release candidate

Added in v24.0.0; a syntax error on Node.js 22 (observed).

node:wasi: WASI

1 Experimental

Added in v13.3.0 and v12.16.0. version is mandatory since v20.0.0, returnOnExit defaults to true since v20.1.0, finalizeBindings() was added in v24.4.0. Not a security sandbox.

--allow-wasi (permission model)

1.1 Active development

Added in v22.3.0 and v20.16.0.

node:ffi (dlopen, DynamicLibrary, suffix, memory helpers)

1 Experimental

Added in v26.1.0; absent on 24 and 22 (observed ERR_UNKNOWN_BUILTIN_MODULE). Enabled by default, disabled with --no-experimental-ffi. Library paths inside a mounted virtual file system are supported since v26.10.0. Available only in builds with FFI support, and only under the node: scheme.

--allow-ffi (permission model)

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.

Table 2. The four ways to run non-JavaScript code
Route What you write and ship Strength Cost

Node-API add-on

C or C++ source, a build description (binding.gyp or CMakeLists.txt), and compiled .node binaries per platform

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 .wasm file (compiled from C, C++, Rust, AssemblyScript or hand-written text format)

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 .wasm command or reactor that imports wasi_snapshot_preview1

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

node:ffi

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.

isbn.c: an ISBN-10 validator through the C Node-API
#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.

binding.gyp and package.json
{
  "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" }
}

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.

Building and loading the add-on
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.

CMakeLists.txt for CMake.js (not executed)
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})

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.

check.mjs: loading the add-on from an ES module and calling it
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');.

Output of the same binary, built once on Node.js 24, run on 22, 24 and 26 (observed)
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

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().

slug.cc and binding.gyp: a C++ function with node-addon-api
#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.

add.mjs: validating and instantiating a module from bytes
// 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)

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.

sum.wat: a module with an import and an exported memory
(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.

sum.mjs: supplying imports and sharing memory
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)

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 and src-phase.mjs: importing WebAssembly through the module system
// 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.

demo.wat (from the official WASI page) and wasi.mjs: running a WASI command
(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.

mylib.c: the shared library, built with 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;
}
call.mjs: dlopen with signatures
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.

callback.mjs: passing a JavaScript function to native code
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:

current.mjs: symbols from the process itself
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

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.

Table 3. Choosing between JavaScript, Node-API, WebAssembly, WASI and FFI
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

node:wasi for trusted programs, otherwise a dedicated WASI runtime or a separate process

The node:wasi documentation itself warns against running untrusted code

A quick prototype or a script that calls a few functions in a library you already have installed

node:ffi on Node.js 26 only, behind --allow-ffi if you use the permission model

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

Table 4. Changes to native-code support (versions from the official documentation, checked 2026-10-04)
Version Change

v26.10.0

ffi.dlopen() and new DynamicLibrary() accept library paths inside a mounted virtual file system (the library bytes are loaded from a private, self-cleaning temporary image).

v26.6.0

ffi.getCurrentEventLoop() added (returns the address of the thread’s uv_loop_t; unsafe).

v26.5.0

The CLI reference says --experimental-addon-modules is enabled by default (importing .node files); the add-ons page still lists the flag and Early development.

v26.1.0

node:ffi and --allow-ffi added (Experimental / 1.1).

v25.5.0 and v24.13.1

Node-API gained napi_float16_array (Float16Array) and support for SharedArrayBuffer.

v24.5.0 and v22.19.0

.wasm modules no longer need --experimental-wasm-modules; reserved wasm: and wasm-js: namespaces.

v24.4.0

wasi.finalizeBindings() added.

v24.0.0

WebAssembly source phase imports (import source) added.

v23.6.0 and v22.20.0

--experimental-addon-modules added.

v22.14.0 and v23.6.0

Node-API version 10 available.

v22.3.0 and v20.16.0

--allow-wasi added.

v21.6.0 and v20.12.0

--allow-addons added.

v20.1.0 and v20.0.0

returnOnExit defaults to true, and the WASI version option is mandatory.

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_VERSION you 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:wasi as a sandbox. It is not one; treat untrusted code as untrusted.

  • Relying on node:ffi in a portable program. It exists only on Node.js 26 and later, only in builds with FFI support, and its API is Experimental.

  • --allow-addons and --allow-ffi in production flags. Both can invalidate the permission model; add them only for the process that needs them.