Native addons with Node-API: the stable ABI and the boundary
Node-API is a stable C ABI: an addon built once keeps loading across Node majors, unlike raw-V8/NAN addons that must be rebuilt. You trade JS handles across an opaque boundary, run blocking work off-thread, and pay a real per-call cost.
A team ships a native image-resize addon built against V8’s headers. It runs fine for a year on Node 16. Then they bump the base image to Node 18 and the service segfaults on the first request — a clean crash with no JavaScript stack, just a core dump deep in v8::Object. Nothing in their code changed. What changed is that V8’s internal C++ ABI shifted between Node majors, and the precompiled .node binary was still calling the old layout. They rebuild against Node 18 headers and it works again — until the next major. They’ve signed up to recompile their addon, forever, against every Node version their users run. The fix was available the whole time: build against Node-API instead, and that one binary would have kept loading across all of those upgrades untouched.
Why Node-API exists: the ABI-stability guarantee
A native addon is a shared library — a .node file, which is really a .so/.dll — that Node loads with process.dlopen and calls like any JavaScript module. The question is what API the compiled binary depends on, because that determines when it breaks. When you know the answer, you can choose an approach that survives major upgrades — and stop signing up for the recompile treadmill the hook describes.
The original C++ addon API exposed V8 directly. Your addon #included v8.h and manipulated v8::Local<v8::Object>, v8::Isolate, v8::FunctionTemplate — V8’s own types. V8 makes no ABI-stability promise across versions: the memory layout of those classes, the vtables, the inline functions all change. So a binary compiled against Node 16’s V8 calls into Node 18’s V8 with the wrong assumptions, and you get the segfault from the hook. NAN (“Native Abstractions for Node.js”) softened this at the source level — macros that papered over the API churn so the same source compiled on many Node versions — but it did nothing for the binary. You still recompiled per Node major.
Node-API (the C API; napi_ functions, formerly “N-API”) cuts the dependency. It is a stable, versioned C ABI that Node guarantees forward-compatible: an addon compiled against Node-API version N keeps running on every future Node release that supports version N or higher, with no recompile. The functions are plain C — no C++ name-mangling, no class layouts to shift — and Node’s own implementation translates napi_ calls into whatever the current V8 wants. You depend on Node-API’s contract, not on V8’s internals.
| Approach | Depends on | Survives a Node major bump? |
|---|---|---|
Raw V8 (v8.h) | V8’s C++ ABI (unstable) | No — rebuild every major, risk of segfault if you don’t |
| NAN | V8 ABI, smoothed at source | Source compiles widely, but the binary still rebuilds per major |
Node-API (napi_*) | Node-API version (stable ABI) | Yes — one binary loads across all Node versions supporting that API level |
In practice almost nobody writes raw napi_ C by hand. node-addon-api is the official C++ wrapper over the C ABI: it gives you Napi::Object, Napi::Number, RAII handle management and exceptions, while still compiling down to the stable C calls underneath. You get C++ ergonomics and the ABI guarantee — that is the modern default.
The boundary: opaque handles, not V8 types
Everything you touch from native code crosses a boundary, and the boundary is deliberately opaque. A JavaScript value arriving in your function is a napi_value — a handle, not a pointer to a V8 object you can dereference. You ask Node-API to read or build values through functions (napi_get_value_double, napi_create_string_utf8), and Node-API does the V8 work. That indirection is exactly what buys ABI stability: your binary never sees a V8 layout.
Here is a minimal addon with node-addon-api — a function that adds two numbers — plus its JavaScript loader.
// addon.cc
#include <napi.h>
Napi::Value Add(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env(); // the napi_env: your handle to the runtime
double a = info[0].As<Napi::Number>(); // marshal JS number -> C double
double b = info[1].As<Napi::Number>();
return Napi::Number::New(env, a + b); // marshal C double -> JS number
}
Napi::Object Init(Napi::Env env, Napi::Object exports) {
exports.Set("add", Napi::Function::New(env, Add));
return exports;
}
NODE_API_MODULE(addon, Init) // register the module entry point// index.js
const addon = require("./build/Release/addon.node");
console.log(addon.add(2, 3)); // 5Two things to notice. First, info.Env() hands you the napi_env — the context token you pass to nearly every Node-API call; it is valid only for the call it is handed to — use the env passed into each callback, never one cached on another thread or held across an async-work boundary. Second, every value crossing the line is marshalled: the JS Number becomes a C double on the way in, and a fresh JS Number is constructed on the way out. There is no shared representation — values are copied or wrapped, never aliased.
▸Why this works
Handles created during a call live in a handle scope that Node-API closes when the function returns, so they don’t leak. But if you create many values inside a loop that runs before returning — say, building a large array element by element — they all pile into that one scope. For long loops, open a Napi::HandleScope (or napi_open_handle_scope) explicitly so intermediate handles are reclaimed promptly instead of accumulating until the call ends.
Async work: never block the event loop thread
Your Add runs synchronously on the main JavaScript thread — the same thread the event loop uses. That is fine for microseconds of arithmetic. It is a disaster for anything slow: a native call that spends 200ms compressing an image blocks the entire event loop, freezing every other request and timer for that whole time. Native code does not magically run in the background; by default it runs where you call it.
The fix is napi_async_work (wrapped as Napi::AsyncWorker). You split the job into an Execute phase that runs on a libuv thread-pool thread — off the main thread, so the event loop keeps serving — and an OnOK/OnError phase that runs back on the main thread to deliver the result. The hard rule: inside Execute you must not touch any napi_value or call any JS-interacting Node-API function, because you are not on the JS thread and the engine is not yours to touch there. You read your inputs into plain C/C++ data before Execute, do the heavy work on raw data, and only convert back to JS values in OnOK.
class ResizeWorker : public Napi::AsyncWorker {
std::vector<uint8_t> input_; // plain data, copied in before Execute
std::vector<uint8_t> output_;
public:
ResizeWorker(Napi::Function cb, std::vector<uint8_t> in)
: Napi::AsyncWorker(cb), input_(std::move(in)) {}
void Execute() override { // libuv thread — NO napi_value here
output_ = heavy_resize(input_);
}
void OnOK() override { // main thread — safe to build JS values
Callback().Call({ Env().Null(), Napi::Buffer<uint8_t>::Copy(Env(), output_.data(), output_.size()) });
}
};For the inverse case — native code on some other thread (a hardware callback, a C library’s worker pool) that needs to call back into JavaScript — you cannot just invoke a napi_value function from that thread. You use a thread-safe function (napi_threadsafe_function), which marshals the call onto the main JS thread safely. Calling JS from a non-JS thread any other way corrupts the engine.
The cost of crossing, and when it pays
Crossing into native is not free. Each call sets up a handle scope, marshals arguments, and tears down — overhead that’s negligible against real work but dominant against trivial work. When you profile a native integration and see it underperform pure JS, the boundary shape is almost always why. A “fast” native function called in a tight loop a million times, each crossing the boundary to do one cheap operation, can easily be slower than the equivalent pure JavaScript, because V8’s JIT optimises the JS path while every native call pays fixed boundary tax and is opaque to the optimiser.
So the boundary shape matters more than the language. Native pays off when you cross rarely and do a lot per crossing: hand a whole buffer to a C codec, call into an existing battle-tested C library (libvips, OpenSSL, a database driver), or run a genuinely CPU-bound kernel that JS can’t match. It loses when the boundary is chatty — many crossings, little work each — where marshalling overhead and lost JIT optimisation eat the gain. Design the API coarse: one call that processes the whole image, not one call per pixel.
You're adding a CPU-heavy native codec to a Node service. How should the JS↔native boundary be shaped?
A precompiled .node addon worked on Node 16 and segfaults after upgrading to Node 18, with no source change. Most likely cause?
Inside an AsyncWorker's Execute() method (running on a libuv thread-pool thread), what must you avoid?
- 01Why does a raw-V8 addon break on a Node major upgrade but a Node-API addon doesn't?
- 02What is the rule about JS values inside an AsyncWorker's Execute, and why does it exist?
A native addon is a shared library Node loads like a module, and the API it compiles against decides when it breaks. Raw-V8 addons bind to V8’s unstable C++ ABI and must be rebuilt every Node major or they segfault; NAN smooths the source but not the binary. Node-API is a stable, versioned C ABI Node guarantees forward-compatible, so one binary loads across all Node versions that support its API level — and node-addon-api is the C++ wrapper that keeps the ergonomics while compiling to those stable C calls; it’s the modern default. Across the boundary every value is an opaque handle, marshalled (copied or wrapped) in and out, never an aliased V8 pointer — which is exactly what buys the ABI stability. Slow work must not run synchronously on the main thread: split it into an AsyncWorker, doing the heavy work in Execute on plain data (no napi_value there) and converting back to JS only in OnOK on the main thread, and use a thread-safe function when a non-JS thread must call back into JavaScript. Finally, every crossing carries fixed cost — handle scope, marshalling, opacity to the JIT — so a chatty per-element boundary can run slower than pure JS. Make the boundary coarse: cross rarely, do a lot per crossing, and reserve native for CPU-bound kernels or existing C libraries where it genuinely wins. Now when you see a “.node addon in our dependency tree” ticket or a profiler report where native calls are slower than expected, you have the vocabulary to diagnose whether the problem is ABI, async, or boundary shape — and which of the three to fix first.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.