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.
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.
# 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 callsNode-API and a native addon end to end
// 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])))"| concern | what to know |
|---|---|
| ABI stability | Node-API is ABI stable across Node majors: one compiled binary works on future versions. The older V8/NAN APIs broke every major. |
| install-time pain | node-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 work | Napi::AsyncWorker runs on the threadpool and completes on the loop; never block the main thread in an addon. |
| thread safety | call into JS from other threads only via Napi::ThreadSafeFunction (part 6). |
WASM, FFI and embedding
| option | good for | cost |
|---|---|---|
| WebAssembly | portable CPU-bound code (image processing, parsers, crypto) with no install step | no direct syscalls or threads without extra plumbing; copying across the boundary |
| Node-API addon | OS access, existing C/C++ libraries, maximum speed | toolchain, prebuilds per platform, memory safety is yours |
| Rust via napi-rs | addons with memory safety and good tooling | build pipeline per platform |
| FFI (koffi, node-ffi-napi) | calling an existing shared library without writing C++ | per-call overhead, unsafe by nature |
| a child process | isolation, any language | process startup and IPC serialisation |
// 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.