C++ Addons (N-API)¶
Sometimes JavaScript just isn't fast enough You're doing image processing at 60fps , running cryptographic operations on every request , or binding to a hardware interface that only ships a C library Native addons let you write C++ code that Node loads as a regular module - callable directly from JavaScript without IPC overhead
Why Native¶
- Performance - C++ runs 10-100x faster for compute-heavy operations
- Existing libraries - use OpenCV , libsodium , ffmpeg , sqlite directly
- Memory control - manual allocation avoids GC pauses
- System access - raw file descriptors , shared memory , kernel interfaces
The tradeoff: complexity , crashes , and maintenance burden A JS bug throws an exception - a C++ bug segfaults your entire process
N-API vs Nan vs Direct V8¶
Three eras of Node native addons:
| Approach | Stability | Node version | Rebuild needed |
|---|---|---|---|
| N-API (node-addon-api) | Stable ABI | Node 10+ | No |
| Nan | Deprecated | Node 0.10+ | Yes (per version) |
| Direct V8 API | Internal | All | Yes (breaks often) |
Use N-API (node-addon-api) for everything new N-API guarantees ABI stability - a module compiled for Node 18 works on Node 22 without recompilation Nan is legacy , direct V8 API is for Node core contributors
node-gyp Setup¶
node-gyp compiles C++ code into a .node file (shared library)
# Install node-gyp globally
npm install -g node-gyp
# Or include as dev dependency
npm install --save-dev node-gyp
// binding.gyp - build configuration (like CMakeLists.txt)
{
"targets": [
{
"target_name": "native_addon",
"sources": ["src/addon.cpp"],
"include_dirs": [
"<!(node -e \"require('node-addon-api').include\")"
],
"defines": ["NAPI_VERSION=9"],
"cflags!": ["-fno-exceptions"],
"cflags_cc!": ["-fno-exceptions"],
"dependencies": ["<!(node -e \"require('node-addon-api').gyp\")"]
}
]
}
// package.json
{
"name": "my-native-addon",
"scripts": {
"install": "node-gyp rebuild",
"build": "node-gyp build"
},
"dependencies": {
"node-addon-api": "^8.0.0"
},
"devDependencies": {
"node-gyp": "^10.0.0"
}
}
# Build the addon
npm run build
# Output: build/Release/native_addon.node
Basic N-API Example¶
// src/addon.cpp
#include <napi.h>
// A native function that adds two numbers
Napi::Number Add(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
// Validate arguments
if (info.Length() < 2) {
Napi::TypeError::New(env, "Two arguments required")
.ThrowAsJavaScriptException();
return Napi::Number::New(env, 0);
}
if (!info[0].IsNumber() || !info[1].IsNumber()) {
Napi::TypeError::New(env, "Arguments must be numbers")
.ThrowAsJavaScriptException();
return Napi::Number::New(env, 0);
}
double a = info[0].As<Napi::Number>().DoubleValue();
double b = info[1].As<Napi::Number>().DoubleValue();
return Napi::Number::New(env, a + b);
}
// Synchronous heavy computation
Napi::Number ComputeHash(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
std::string input = info[0].As<Napi::String>().Utf8Value();
int iterations = info[1].As<Napi::Number>().Int32Value();
// Simulate CPU-bound work
unsigned long hash = 5381;
for (int i = 0; i < iterations; i++) {
for (char c : input) {
hash = ((hash << 5) + hash) + c;
}
}
return Napi::Number::New(env, static_cast<double>(hash));
}
// Initialize the module
Napi::Object Init(Napi::Env env, Napi::Object exports) {
exports.Set("add", Napi::Function::New(env, Add));
exports.Set("computeHash", Napi::Function::New(env, ComputeHash));
return exports;
}
NODE_API_MODULE(native_addon, Init)
// index.js - load and use the native addon
const addon = require('./build/Release/native_addon')
console.log(addon.add(5, 3)) // 8
console.log(addon.computeHash('hello', 1000)) // some big number
// Error handling
try {
addon.add('not a number')
} catch (err) {
console.error('Native error:', err.message)
}
Async Work - Don't Block the Event Loop¶
A synchronous native function blocks the Event Loop just like synchronous JS
// src/async-worker.cpp
#include <napi.h>
#include <thread>
class AsyncWorker : public Napi::AsyncWork {
public:
AsyncWorker(Napi::Function& callback, std::string input, int iterations)
: Napi::AsyncWork(callback), input(input), iterations(iterations) {}
void Execute() override {
// Runs on libuv thread pool - doesn't block Event Loop
unsigned long hash = 5381;
for (int i = 0; i < iterations; i++) {
for (char c : input) {
hash = ((hash << 5) + hash) + c;
}
}
result = hash;
}
void OnOK() override {
// Runs on main thread - safe to call back into JS
Napi::HandleScope scope(Env());
std::vector<napi_value> args = {
Env().Null(),
Napi::Number::New(Env(), static_cast<double>(result))
};
Callback().Call(args);
}
private:
std::string input;
int iterations;
unsigned long result;
};
// Expose async function
Napi::Value ComputeHashAsync(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
std::string input = info[0].As<Napi::String>().Utf8Value();
int iterations = info[1].As<Napi::Number>().Int32Value();
Napi::Function callback = info[2].As<Napi::Function>();
auto* worker = new AsyncWorker(callback, input, iterations);
worker->Queue();
return env.Undefined();
}
AsyncWork uses libuv's thread pool - default size is 4 threads Increase with UV_THREADPOOL_SIZE=8 if needed (but test first)
When to Use Native Addons¶
- Crypto - custom cryptographic operations , hardware security module bindings
- Image processing - sharp (libvips) , leptonica , OpenCV bindings
- Audio/video - ffmpeg bindings , real-time audio processing
- Serialization - protocol buffers , msgpack , custom binary formats
- Hardware - GPIO , USB , serial port , camera interfaces
- Game engines - physics simulation , rendering pipelines
When NOT to use: - Database drivers (use existing Node.js clients) - HTTP clients (fetch/undici handle this) - File system operations (fs module is fast enough) - Anything you can do with Worker Threads (workers are easier to maintain)
Security - Native Addons Bypass the JS Sandbox¶
// C++ code can access any process memory
#include <napi.h>
#include <cstring>
Napi::Value ReadMemory(const Napi::CallbackInfo& info) {
// This is DANGEROUS - reads arbitrary memory
uintptr_t addr = info[0].As<Napi::Number>().Int64Value();
size_t len = info[1].As<Napi::Number>().Int64Value();
char* buffer = new char[len];
memcpy(buffer, reinterpret_cast<void*>(addr), len);
return Napi::Buffer<char>::New(info.Env(), buffer, len);
}
A native addon can: - Read and write any process memory - Execute arbitrary machine code - Bypass all JavaScript security boundaries - Crash the process with a segfault - Introduce memory leaks that GC can't touch
// Segfault - C++ bug that crashes the process
Napi::Value BadPtr(const Napi::CallbackInfo& info) {
int* ptr = nullptr;
*ptr = 42; // segfault - whole Node process dies
return info.Env().Undefined();
}
Defense: - Only use well-audited native modules from trusted sources - Audit the C++ code yourself if it handles untrusted input - Run native addons in isolated child processes when possible - Test for memory leaks under load (valgrind , asan) - Consider if a Worker Thread is sufficient before writing C++
Prerequisites¶
- adv_02_worker_threads.md - understand when workers are enough
next -> adv_04_pwas.md