Skip to content

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


next -> adv_04_pwas.md