Part 5 · 3 chapters · ~18 min

The Native Layer and Extending Node

How a JavaScript call reaches C++, Node-API and node-addon-api with their ABI stability contract, writing a native addon end to end, node-gyp and prebuilds, WebAssembly as the alternative, FFI options, and embedding Node in another application.

17

The binding layer

Every Node API that touches the OS ends in C++ in src/. Knowing the path explains three production facts: native calls cost a boundary crossing (converting values, creating handles), completion always comes back through the loop, and async context is restored in one place (MakeCallback), which is why context-losing bugs cluster around native callbacks in old addons.

code
# follow a call yourself
git grep -n "SetMethod(.*\"open\"" src/node_file.cc
git grep -n "function readFile" lib/fs.js
node --trace-event-categories node.fs.sync -e "require('fs').readFileSync(__filename)"   # trace fs calls
A JS CALL REACHING C++
fs.readFile, from JavaScript through the binding to libuv and back
lib/fs.jssrc/node_file.cclibuvthreadpoolkernelbinding.open(path, cb)
swipe the figure sideways, or tap expand for full screen
1/5
the call
lib/fs.js validates arguments in JavaScript, then calls a function on the fs binding object obtained via internalBinding("fs"). That function is a C++ function registered with V8.
JS validates, then calls a C++ functioninternalBinding("fs") returns the binding object
18

Node-API and a native addon end to end

code
// addon.cc: a sum function over a Float64Array, with node-addon-api
#include <napi.h>
Napi::Value Sum(const Napi::CallbackInfo& info) {
  auto arr = info[0].As<Napi::Float64Array>();
  double s = 0; for (size_t i = 0; i < arr.ElementLength(); i++) s += arr[i];
  return Napi::Number::New(info.Env(), s);
}
Napi::Object Init(Napi::Env env, Napi::Object exports) {
  exports.Set("sum", Napi::Function::New(env, Sum)); return exports;
}
NODE_API_MODULE(addon, Init)

# binding.gyp
{ "targets": [{ "target_name": "addon", "sources": ["addon.cc"],
    "include_dirs": ["<!(node -p \"require('node-addon-api').include_dir\")"],
    "defines": ["NAPI_DISABLE_CPP_EXCEPTIONS"] }] }

# build and use
npx node-gyp configure build
node -e "console.log(require('./build/Release/addon.node').sum(new Float64Array([1,2,3])))"
concernwhat to know
ABI stabilityNode-API is ABI stable across Node majors: one compiled binary works on future versions. The older V8/NAN APIs broke every major.
install-time painnode-gyp needs Python and a C++ toolchain on the user's machine; ship prebuilds (prebuildify, or optionalDependencies per platform like esbuild and swc do).
async workNapi::AsyncWorker runs on the threadpool and completes on the loop; never block the main thread in an addon.
thread safetycall into JS from other threads only via Napi::ThreadSafeFunction (part 6).
19

WASM, FFI and embedding

optiongood forcost
WebAssemblyportable CPU-bound code (image processing, parsers, crypto) with no install stepno direct syscalls or threads without extra plumbing; copying across the boundary
Node-API addonOS access, existing C/C++ libraries, maximum speedtoolchain, prebuilds per platform, memory safety is yours
Rust via napi-rsaddons with memory safety and good toolingbuild pipeline per platform
FFI (koffi, node-ffi-napi)calling an existing shared library without writing C++per-call overhead, unsafe by nature
a child processisolation, any languageprocess startup and IPC serialisation
code
// WASM in Node: load and call
import { readFile } from 'node:fs/promises';
const { instance } = await WebAssembly.instantiate(await readFile('sum.wasm'));
console.log(instance.exports.sum(2, 3));

Embedding: Node can be linked into another C++ program (node::CreateEnvironment and the embedder API); Electron does this, combining Node's Environment with Chromium's renderer loop. The rule of thumb: reach for native code only with a profile proving JavaScript is the bottleneck (part 9), and prefer WASM when you do not need the OS.