NodeJSNative Addons (N-API)

Native Addons (N-API)

Native addons are C or C++ modules that compile to .node binary files and are loaded by Node.js like any other module. They're the bridge between JavaScript and native code: you write performance-critical or hardware-interfacing logic in C/C++, compile it to a native addon, and call it from JavaScript with a normal require(). N-API (Node-API) is the stable C API layer that insulates addon code from V8 internals — an addon written against N-API compiles once and runs across Node versions without recompilation, unlike the older nan (Native Abstractions for Node.js) approach.

When native addons are worth it

Good reason

Poor reason

Wrapping an existing C/C++ library (libsodium, OpenCV, SQLite)

JavaScript is slightly slower — profile first, optimize JS first

CPU-bound work that saturates JavaScript (image processing, codecs, compression)

Premature optimization without measurement

Hardware access (serial ports, GPU APIs, custom drivers)

Not knowing about WebAssembly, which is often a simpler path

Algorithmic hot paths that truly need pointer-level memory control

Complexity aversion — N-API is complex; WebAssembly may be simpler

Consider WebAssembly before native addons — Wasm compiles from C/C++/Rust, runs in the same V8 sandbox as JavaScript, and has no N-API binding layer
Before writing a native addon, evaluate [WebAssembly](/nodejs/wasm): compile your C/C++ or Rust code to Wasm with Emscripten or wasm-pack, and call it from JavaScript with no N-API binding layer. Wasm runs inside V8's sandbox (same memory isolation as JavaScript), compiles once and runs anywhere Node runs, and avoids the complexity of managing N-API handles and scopes. Native addons are the right choice when you need to **call into an existing C/C++ library that has no Wasm port**, when you need **OS-level system calls** that Wasm can't make, or when you need maximum performance with manual memory control. For pure algorithmic hot paths, Wasm often matches native performance without the operational complexity.
The N-API architecture

Text
JavaScript (your Node.js code)
    │
    │  require('./build/Release/my-addon.node')
    ▼
N-API layer  (stable C ABI — does NOT change between Node versions)
    │
    │  napi_create_function, napi_get_value_int32, napi_call_function ...
    ▼
Your C/C++ addon code  (my-addon.cc)
    │
    │  calls into native libraries, OS APIs, hardware
    ▼
Native libraries / OS
Setting up a native addon project

Bash
npm install --save-dev node-gyp
npm install node-addon-api   # C++ wrapper over raw N-API (much easier to use)

JSON
// package.json — scripts to build the addon:
{
  "scripts": {
    "build": "node-gyp rebuild",
    "install": "node-gyp rebuild"   // auto-build on npm install
  }
}

Python
# binding.gyp — build configuration (GYP format):
{
  "targets": [{
    "target_name": "my_addon",
    "sources": ["src/my-addon.cc"],
    "include_dirs": [
      "<!@(node -p "require('node-addon-api').include")"
    ],
    "defines": ["NAPI_DISABLE_CPP_EXCEPTIONS"]
  }]
}
Writing a simple addon with node-addon-api

CPP
// src/my-addon.cc
#include <napi.h>

// A function that adds two numbers — trivial but shows the pattern:
Napi::Value Add(const Napi::CallbackInfo& info) {
  Napi::Env env = info.Env();

  // Validate argument count and types:
  if (info.Length() < 2 || !info[0].IsNumber() || !info[1].IsNumber()) {
    Napi::TypeError::New(env, "Expected two numbers").ThrowAsJavaScriptException();
    return env.Null();
  }

  double a = info[0].As<Napi::Number>().DoubleValue();
  double b = info[1].As<Napi::Number>().DoubleValue();

  return Napi::Number::New(env, a + b);
}

// Module initialization — called once when the module is loaded:
Napi::Object Init(Napi::Env env, Napi::Object exports) {
  exports.Set("add", Napi::Function::New(env, Add));
  return exports;
}

NODE_API_MODULE(my_addon, Init)

TS
// Loading and using the compiled addon:
// eslint-disable-next-line @typescript-eslint/no-require-imports
const addon = require('./build/Release/my_addon')

console.log(addon.add(3, 4))   // 7
Async addons — keeping the event loop free

CPP
// Async worker pattern — runs C++ work on the libuv thread pool, not the event loop:
#include <napi.h>

class HashWorker : public Napi::AsyncWorker {
public:
  HashWorker(Napi::Function& callback, std::string input)
    : Napi::AsyncWorker(callback), input_(std::move(input)) {}

  // Execute() runs on the thread pool — NO JavaScript calls allowed here:
  void Execute() override {
    result_ = computeExpensiveHash(input_);  // pure C++ work
  }

  // OnOK() runs back on the main thread — safe to call JavaScript:
  void OnOK() override {
    Napi::HandleScope scope(Env());
    Callback().Call({Env().Null(), Napi::String::New(Env(), result_)});
  }

private:
  std::string input_;
  std::string result_;
  std::string computeExpensiveHash(const std::string& s) { /* ... */ return s; }
};

Napi::Value ComputeHash(const Napi::CallbackInfo& info) {
  std::string input = info[0].As<Napi::String>();
  Napi::Function callback = info[1].As<Napi::Function>();
  auto* worker = new HashWorker(callback, input);
  worker->Queue();   // schedule on thread pool
  return info.Env().Undefined();
}
Never call JavaScript (napi_call_function, Napi::Value access) from Execute() — only from OnOK()/OnError() which run on the main thread
`Execute()` runs on libuv's thread pool, not the JavaScript thread. V8 is not thread-safe — calling any N-API JavaScript-interacting function from a background thread causes crashes, corrupted heap, or undefined behavior. The `AsyncWorker` pattern exists specifically to enforce this: heavy CPU or blocking I/O work happens in `Execute()` (pure C++, no V8), and the result is reported back via `OnOK()`/`OnError()` which are called on the main thread where V8 access is safe. If you need multi-threaded JavaScript communication, use Worker Threads on the JavaScript side rather than trying to call V8 from multiple C++ threads.
Distributing addons — prebuilt binaries

Bash
# Building requires a C++ compiler and Python on each machine — painful for npm install.
# Use node-pre-gyp or prebuildify to publish prebuilt binaries:

npm install --save-dev prebuildify
# Builds .node files for multiple platforms/architectures
# Uploads to GitHub Releases; downloaded at install time on target machine

# package.json:
# "install": "node-pre-gyp install --fallback-to-build"
# This downloads a prebuilt binary if available, or falls back to compiling from source.
Ship prebuilt binaries for your target platforms — requiring users to compile C++ at install time is a major source of npm install failures on CI and developer machines
A native addon that requires compilation at install time will fail on machines without a C++ toolchain (many CI images, Windows developer machines, minimal Docker containers). Prebuilt binaries solve this: you compile the addon for each target platform (Linux x64, Linux arm64, macOS x64, macOS arm64, Windows x64) in CI and publish the `.node` files to a release artifact store. At install time, `node-pre-gyp` downloads the right prebuilt binary and falls back to compilation if none matches. This is how well-maintained addons like `bcrypt`, `better-sqlite3`, and `sharp` work. If you're publishing a native addon as an npm package, prebuilt binaries are not optional — they're a prerequisite for the package being usable.
Next
Writing C/C++ addons in depth: [C/C++ Addons](/nodejs/c-cpp-addons).