Native Addons N-API WASM FFI and Embedding

Reading time
11 min read
Word count
2193 words
Diagram count
0 diagrams

Source: Victor Bona's Obsidian Compendium snapshot, Knowledge base/nodejs-v8-runtime-engineering/13 Native Addons N-API WASM FFI and Embedding.md.

Purpose: Field manual for crossing Node.js runtime boundaries with native addons, Node-API, WebAssembly, WASI, experimental FFI, and embedding, grounded in Node.js V8 Runtime Engineering and connected back to 11 Networking HTTP TLS DNS Sockets Undici and Fetch plus 12 Web Platform APIs in Node.js URL Blob Web Streams AbortController and Test Runner.

Native Addons N-API WASM FFI and Embedding

Native boundaries are where Node.js stops being "just JavaScript" and becomes a host for C, C++, Rust, Zig, system libraries, WebAssembly modules, and embedded runtimes. These boundaries can unlock performance, legacy integration, hardware access, and portability, but they also bypass much of the safety that makes ordinary Node services operable.

Default decision rule: stay in JavaScript until measurement or integration constraints prove otherwise. When you cross the boundary, design the boundary as a product API with ABI policy, memory ownership, thread rules, error mapping, packaging, observability, and security review.

Boundary selection

NeedPreferWhyWatch
Stable native addon across Node versionsNode-APIABI-stable C API independent of V8 details.Async work, references, and finalizers still need discipline.
Ergonomic C++ addon over Node-APInode-addon-apiHeader-only C++ wrapper around Node-API.C++ exceptions and wrapper overhead policy.
Maximum V8 controlV8 and classic C++ addon APIsDirect engine integration and advanced object behavior.ABI churn, isolate/context correctness, worker compatibility.
Portable sandboxed computeWebAssemblySafe module boundary and multi-language compilation target.Host calls, memory copies, startup, WASI limitations.
POSIX-like WASM programWASIFilesystem/env/args/preopen model for WASM apps.Experimental surface and capability design.
Call an existing shared library quicklynode:ffiNo compilation step for wrapper addon.Experimental, unsafe, flag-gated, pointer correctness.
Embed Node in another C++ hostC++ embedder APIPut Node event loop and JS runtime inside a larger app.Lifecycle, libuv loop ownership, platform initialization.

Native boundary design checklist

AreaQuestions
ABIWhich Node versions and platforms are supported? Is the boundary Node-API or V8-specific?
MemoryWho allocates, who frees, and when can JavaScript GC move or release references?
ThreadsWhich thread may call into JS? How do background threads communicate with the main event loop?
ErrorsHow are native status codes mapped to JS exceptions or Result-like objects?
AsyncIs work executed off the event loop? Is cancellation supported?
PackagingAre binaries prebuilt, compiled at install time, or loaded from system libraries?
SecurityCan malformed input crash the process or corrupt memory? Is fuzzing required?
ObservabilityCan errors and latency be attributed to native calls?
TestsAre ABI, platform, concurrency, and teardown paths covered?

Node-API

Node-API, formerly N-API, is the preferred native addon interface for long-lived packages because it is maintained by Node.js and designed to be ABI-stable across Node versions. It abstracts JavaScript values behind opaque handles and returns status codes for calls.

Core concepts:

ConceptMeaning
napi_envEnvironment for a Node instance and context. Do not treat it as globally portable.
napi_valueOpaque handle to a JavaScript value.
napi_statusReturn code from Node-API calls. Always check it.
Handle scopeLifetime boundary for temporary JS handles.
ReferencePersistent link to a JS value across calls. Must be released.
FinalizerNative cleanup hook associated with JS object lifetime.
Async workLibuv-backed async operation exposed through Node-API.
Thread-safe functionSafe path for native threads to call JS on the correct thread.

Minimal C addon shape:

#include <node_api.h>

static napi_value Add(napi_env env, napi_callback_info info) {
  size_t argc = 2;
  napi_value args[2];
  napi_get_cb_info(env, info, &argc, args, NULL, NULL);

  double a;
  double b;
  napi_get_value_double(env, args[0], &a);
  napi_get_value_double(env, args[1], &b);

  napi_value result;
  napi_create_double(env, a + b, &result);
  return result;
}

NAPI_MODULE_INIT() {
  napi_value fn;
  napi_create_function(env, "add", NAPI_AUTO_LENGTH, Add, NULL, &fn);
  napi_set_named_property(env, exports, "add", fn);
  return exports;
}

The example is intentionally small. Production code must check every napi_status, validate argument count and types, and map errors deliberately.

Node-API error pattern:

#define NAPI_CALL(env, call)                                      \
  do {                                                            \
    napi_status status = (call);                                  \
    if (status != napi_ok) {                                      \
      const napi_extended_error_info* info;                       \
      napi_get_last_error_info((env), &info);                     \
      const char* message = info && info->error_message           \
          ? info->error_message                                  \
          : "Node-API call failed";                              \
      napi_throw_error((env), NULL, message);                     \
      return NULL;                                                \
    }                                                             \
  } while (0)

Node-API production guidance:

ConcernGuidance
Status checksWrap and check every call. Native errors without JS exceptions are debugging pain.
TypesValidate JS input before extracting native values.
LifetimesKeep native pointers behind finalizers or explicit close methods.
ReferencesUse persistent references only when necessary and release them.
AsyncMove CPU or blocking IO off the event loop.
Thread callbacksUse thread-safe functions instead of calling JS from arbitrary native threads.
ABI versionDeclare supported Node-API version and test the oldest supported Node.

node-addon-api

node-addon-api is the official C++ wrapper over Node-API. It improves ergonomics but the same lifetime and threading rules remain.

Example shape:

#include <napi.h>

Napi::Value Add(const Napi::CallbackInfo& info) {
  Napi::Env env = info.Env();

  if (info.Length() != 2 || !info[0].IsNumber() || !info[1].IsNumber()) {
    Napi::TypeError::New(env, "expected two numbers").ThrowAsJavaScriptException();
    return env.Null();
  }

  double result = info[0].As<Napi::Number>().DoubleValue()
      + info[1].As<Napi::Number>().DoubleValue();
  return Napi::Number::New(env, result);
}

Napi::Object Init(Napi::Env env, Napi::Object exports) {
  exports.Set("add", Napi::Function::New(env, Add));
  return exports;
}

NODE_API_MODULE(addon, Init)

Packaging checklist:

File or settingPurpose
binding.gypNative build configuration for node-gyp.
prebuild or package toolingOptional prebuilt binaries for supported platforms.
engines.nodeCommunicates Node support policy.
CI matrixOS, architecture, Node versions, debug/release builds.
Runtime load errorClear message when binary is missing or incompatible.

Classic C++ addons and V8-specific APIs

Classic addons use V8, libuv, and internal Node APIs more directly. This can be necessary for deep engine integration, but it binds the addon to V8 and Node internals more tightly than Node-API.

Context-aware addon rules:

  • Avoid global static state containing JS references.
  • Store per-instance data and pass it to callbacks.
  • Assume the addon can be loaded in multiple contexts or worker threads.
  • Clean up with environment cleanup hooks or finalizers.
  • Do not share V8 handles across isolates.

Use V8-specific addons only when:

  • Node-API cannot represent the required behavior.
  • The maintenance team can track Node and V8 changes.
  • CI tests every supported Node version.
  • The addon has strong crash, memory, and concurrency tests.

Threading and async work

Native code must not block the event loop for expensive CPU work or blocking system calls. Use async work queues, worker threads, native threads, or external services depending on shape.

Thread rules:

RuleReason
JS values belong to an environment and thread context.Direct cross-thread use is unsafe.
Use thread-safe functions for native-to-JS callbacks.They marshal calls onto the correct JS thread.
Reference or copy data needed after the JS call returns.Temporary handles expire.
Make cancellation explicit.Native work will not stop because a JS Promise was abandoned.
Release native resources on all paths.Process lifetime hides leaks until load or tests reveal them.

Async design options:

ShapeUse when
Node-API async workShort native job scheduled off event loop.
Worker thread plus addonCPU-heavy JS/native orchestration with isolated JS context.
Native thread plus thread-safe functionExternal library owns callback thread.
Child processCrash isolation matters more than shared memory performance.
WASMPortability and memory isolation matter.

WebAssembly in Node.js

WebAssembly is often the cleanest boundary for portable compute. Node exposes the standard WebAssembly global. WASM modules run with linear memory and imported host functions.

Good fits:

  • Deterministic compute kernels.
  • Parsers and codecs compiled from Rust, C, C++, or Zig.
  • Plugin-like execution where memory isolation helps.
  • Cross-runtime code shared with browsers and edge.

Poor fits:

  • Heavy direct OS integration without WASI or host functions.
  • Native libraries with complex callback and pointer ownership.
  • Tasks dominated by JS-to-WASM boundary crossing.
  • Work requiring direct access to Node objects.

Example: instantiate a WASM module.

import { readFile } from 'node:fs/promises';

export async function loadModule(path) {
  const bytes = await readFile(path);
  const module = await WebAssembly.compile(bytes);
  return WebAssembly.instantiate(module, {
    env: {
      log(value) {
        console.log({ value });
      },
    },
  });
}

WASM performance checklist:

ConcernGuidance
Boundary callsBatch data to reduce JS/WASM crossings.
Memory copiesPass offsets and lengths where appropriate; copy only at safe boundaries.
StartupCache compiled modules when possible.
ErrorsMap traps and module errors to typed JS errors.
SecurityTreat host imports as the capability surface.

WASI

WASI gives WebAssembly programs POSIX-like access through explicit capabilities such as args, env, and preopened directories. In Node, the node:wasi module provides a WASI implementation and requires selecting a WASI version such as preview1.

Use WASI when:

  • You have a CLI-like WASM artifact.
  • The module needs filesystem-like access through preopens.
  • You want a capability boundary around allowed paths.

Example:

import { readFile } from 'node:fs/promises';
import { WASI } from 'node:wasi';
import { argv, env } from 'node:process';

const wasi = new WASI({
  version: 'preview1',
  args: argv,
  env,
  preopens: {
    '/work': '/srv/worker/input',
  },
});

const wasm = await WebAssembly.compile(await readFile('./tool.wasm'));
const instance = await WebAssembly.instantiate(wasm, wasi.getImportObject());
wasi.start(instance);

WASI footguns:

FootgunConsequence
Broad preopensWASM gets more filesystem reach than intended.
Passing full environmentSecrets leak into module.
Assuming POSIX completenessWASI preview support is not a full OS.
Treating WASM as harmlessHost imports can grant powerful capabilities.

Experimental FFI

Node v26 introduced node:ffi as an experimental module for loading dynamic libraries and calling native symbols. It is unsafe, gated by --experimental-ffi, available only under the node: scheme in builds with FFI support, and restricted by the permission model unless --allow-ffi is provided.

Use FFI for:

  • Internal experiments.
  • Thin calls to stable C ABI functions.
  • Operational tooling where process crash risk is acceptable.
  • Avoiding a compile step for a small trusted integration.

Avoid FFI for:

  • Untrusted inputs.
  • Complex ownership and callbacks.
  • Long-lived public packages that need stable support.
  • Security-sensitive service hot paths.

Example shape:

import { dlopen } from 'node:ffi';

using handle = dlopen('./libmath.so', {
  add_i32: {
    arguments: ['i32', 'i32'],
    return: 'i32',
  },
});

console.log(handle.functions.add_i32(20, 22));

FFI safety checklist:

RiskGuard
Wrong signatureGenerate bindings from headers or keep a reviewed signature table.
Freed memoryDefine owner and lifetime for every pointer.
Resized backing storeDo not detach, transfer, or resize buffers during native calls.
Callback lifetimeRegister, reference, unreference, and unregister deliberately.
Platform ABI driftTest OS, architecture, library version, and compiler ABI.
Process crashIsolate risky calls in a worker or child process when possible.

Embedding Node and V8

Embedding means a C++ host application creates and runs a Node.js environment instead of launching node as the top-level process. This is specialized work for databases, desktop apps, appliances, custom runtimes, or platforms that need JavaScript extensibility.

Embedding concerns:

ConcernWhy it matters
V8 platformV8 needs platform initialization and task scheduling.
IsolateHeap and JS execution live inside an isolate.
ContextGlobal object and built-ins are context-specific.
libuv loopNode async IO depends on event loop integration.
EnvironmentNode environment binds V8, libuv, process state, and modules.
ShutdownCleanup order matters for handles, microtasks, finalizers, and platform state.

Embedding is not a plugin system by itself. You still need policy:

  • Which modules can user code load?
  • How is filesystem and network access restricted?
  • How are CPU and memory bounded?
  • How are diagnostics captured?
  • How are crashes isolated?
  • How are V8 and Node upgraded?

Error mapping

Native errors should become predictable JavaScript errors.

Native conditionJS mapping
Invalid argumentTypeError with parameter name and expected shape.
Domain errorCustom Error subclass with code.
System errorError with code, errno, syscall, and path or address when safe.
Resource closedIdempotent close or ERR_RESOURCE_CLOSED style error.
CancellationAbortError-compatible error when driven by AbortSignal.
Panic or invariant failureConvert if recoverable; otherwise crash fast with report.

Avoid returning mixed shapes such as sometimes null, sometimes false, sometimes exception. Native boundary APIs are hard enough to debug without ambiguous failure contracts.

Security and reliability

Native boundary review checklist:

AreaReview
Input validationLengths, encodings, integer ranges, null pointers, enum ranges.
Memory safetyBounds checks, ownership, double free, use after free, finalizer ordering.
Thread safetyRace conditions, JS callbacks from native threads, shared globals.
Event loopBlocking calls, long CPU loops, sync filesystem, hidden locks.
Supply chainPrebuilt binaries, install scripts, system libraries, provenance.
SandboxWASI preopens, FFI allow list, worker or child process isolation.
ObservabilityNative call latency, error codes, crash reports, heap snapshots.
FuzzingParsers and binary input boundaries fuzzed outside Node and through JS API.

Operational controls:

  • Enable diagnostic reports for crashes in production environments.
  • Keep symbol files for native builds.
  • Test with sanitizers in CI for native code.
  • Run stress tests that load and unload addons repeatedly.
  • Verify worker thread behavior if the package can be imported in workers.
  • Document platform support explicitly.

Troubleshooting

SymptomLikely causeFirst check
MODULE_NOT_FOUND for .node binaryBuild artifact missing or wrong path.Package files, install output, platform tuple.
Module did not self-registerABI mismatch or bad initializer.Node version, Node-API version, addon macro.
Crash after callbackJS called from wrong thread or stale reference.Thread-safe function usage and reference lifecycle.
Works on main thread, fails in workerNot context-aware or uses unsafe globals.Addon initialization and per-instance data.
Memory grows after testsReferences or native resources not released.Finalizers, close methods, heap snapshots.
WASI cannot read fileMissing or wrong preopen mapping.WASI config and module path expectations.
FFI crashes immediatelyWrong signature or pointer type.Header definition, calling convention, return type.
Embedder hangs on shutdownActive handles or wrong cleanup order.libuv handles, environment teardown, pending microtasks.

Decision examples

ScenarioGood choiceReason
Image codec with mature C library and high throughputNode-API addon or child process wrapperPerformance and library reuse justify native code; crash isolation may matter.
Small call to system library in internal toolExperimental FFIFast integration if process crash risk is acceptable.
User-supplied compute pluginWASM plus narrow host importsStronger capability boundary than native addon.
Existing Rust parser used by browser and NodeWASM or Node-API through napi-rsChoose based on boundary call cost and packaging needs.
Need to expose JS scripting inside a C++ applicationEmbed NodeHost owns lifecycle and can integrate event loop deliberately.

Official reference anchors checked