Zum Inhalt springen
Zurück zum Blog
13 Min. Lesezeit

What is N-API? Node.js native addons by example

Node.js
N-API
C
Native Addons

Node.js is JavaScript on the outside — but the runtime itself is C++, and sometimes you need to reach below the JavaScript layer: to bind an existing C library, to talk to hardware, or to hand a hot loop to native code. The official bridge for that is N-API (today formally called Node-API).

I used N-API to build storj-uplink-nodejs, a TypeScript-first binding over Storj's C library. This post is the introduction I wish I'd had on day one: what N-API actually is, when it's the right tool, and four small working examples — one for each kind of value you'll pass across the boundary. Every snippet below compiles and runs; the full repo is linked at the bottom.

What is N-API?

N-API is a C API built into Node.js for writing native addons — shared libraries (.node files) that require() loads like any other module, except the functions inside are compiled machine code.

Native addons existed long before N-API, but they were written directly against V8's C++ API. That came with a painful catch: V8's internals change between Node versions, so every Node upgrade meant recompiling — and often patching — your addon. The community coped with wrapper layers like NAN ("Native Abstractions for Node"), which papered over the differences but still required rebuilding per version.

N-API fixed this with one core promise: ABI stability. The API is a flat set of C functions (napi_create_double, napi_get_value_string_utf8, …) that are guaranteed not to break across Node.js major versions. Node-API itself is versioned additively: compile your addon against Node-API version N, and the same binary loads on every Node.js release that supports version N — in practice, Node 18, 20, 22, and whatever comes next — as long as the addon sticks to Node-API calls (no direct V8 or internal Node headers) and its own native dependencies stay ABI-compatible too. That property is what makes shipping prebuilt binaries practical — your users run npm install and never see a compiler.

Two more things worth knowing:

  • You don't touch V8 types. Every JavaScript value crosses the boundary as an opaque handle (napi_value). You convert it to C data with napi_get_value_* functions and build return values with napi_create_*.
  • C++ is optional. There's a header-only C++ wrapper (node-addon-api) that many projects use, but underneath it's all this C API. Writing pure C keeps every allocation and every conversion visible — that's what we'll do here.

When to use N-API

  • Binding an existing C/C++ library. The #1 use case. Compression codecs, database drivers, crypto, computer vision, vendor SDKs — if the battle-tested implementation exists in C, bind it instead of rewriting it.
  • CPU-bound hot paths. Tight numeric loops, hashing, image transforms — code where V8's JIT isn't enough and the work dwarfs the cost of crossing the JS↔C boundary.
  • System and hardware access. Anything Node's standard library doesn't expose: USB devices, shared memory, platform-specific syscalls.
  • When you need one binary for many Node versions. ABI stability means prebuilt binaries per platform, not per platform × Node version.

When not to use N-API

  • I/O-bound work. Node is already excellent at I/O — the event loop and libuv give you async file and network handling for free. Native code buys you nothing there.
  • Business logic. If it's not performance-critical and not binding a native library, JavaScript is easier to write, test, debug, and hire for. A native addon is a maintenance commitment: toolchains, cross-platform CI, memory safety.
  • When WebAssembly fits. If your native code is pure computation with no system calls, WASM gives you near-native speed with none of the platform-specific build pain — and it runs in the browser too. N-API wins when you need real OS access or an existing shared library.
  • Tiny functions called millions of times. Crossing the boundary has a fixed cost. add(2, 3) in C is slower than in JS once you pay the call overhead — the examples below are for learning the mechanics, not for speed.

Project setup

A minimal addon is three files. binding.gyp tells node-gyp (Node's native build tool) what to compile:

{
  "targets": [
    {
      "target_name": "napi_examples",
      "sources": ["src/addon.c"]
    }
  ]
}

package.json wires up the scripts:

{
  "scripts": {
    "build": "node-gyp rebuild",
    "test": "node test.js"
  },
  "devDependencies": { "node-gyp": "^11.0.0" }
}

And index.js loads the compiled binary like any module:

module.exports = require('./build/Release/napi_examples.node')

All the C code lives in src/addon.c, which starts with three includes — node_api.h brings in every napi_* function and type, and the other two give us malloc/free and uint8_t:

#include <node_api.h>
#include <stdint.h>
#include <stdlib.h>

Every binding has the same C signature — it receives an environment handle and callback info, and returns a JS value:

static napi_value MyFunction(napi_env env, napi_callback_info info)

One helper macro keeps error handling readable — it throws a normal JavaScript TypeError that the caller can try/catch:

#define THROW_AND_RETURN(env, msg)         \
  do {                                     \
    napi_throw_type_error(env, NULL, msg); \
    return NULL;                           \
  } while (0)

One honesty note before the code: every napi_* function returns a napi_status, and production bindings check all of them. To keep these listings readable, I check the calls whose output feeds the next step (argument conversions, property reads) and skip the checks on calls that can't realistically fail here (napi_get_cb_info with stack arrays, napi_create_double, napi_get_boolean). Know that you're seeing the 90% version — the pattern for the remaining 10% is the same != napi_ok guard.

Now the four examples.

1. Numbers: add(a, b)

JavaScript numbers are IEEE 754 doubles, so the natural C type is double. The pattern you'll see in every binding: unpack the arguments with napi_get_cb_info, convert handles to C values, do the work, wrap the result.

static napi_value Add(napi_env env, napi_callback_info info) {
  size_t argc = 2;
  napi_value argv[2];
  napi_get_cb_info(env, info, &argc, argv, NULL, NULL);
  if (argc < 2) THROW_AND_RETURN(env, "add(a, b) expects two numbers");
 
  double a, b;
  if (napi_get_value_double(env, argv[0], &a) != napi_ok ||
      napi_get_value_double(env, argv[1], &b) != napi_ok) {
    THROW_AND_RETURN(env, "add(a, b): both arguments must be numbers");
  }
 
  napi_value result;
  napi_create_double(env, a + b, &result);
  return result;
}
const { add } = require('./index.js')
 
add(2, 3)      // 5
add(0.1, 0.2)  // 0.30000000000000004 — same doubles as JS itself
add('x', 'y')  // TypeError: add(a, b): both arguments must be numbers

Note the last line: because we throw with napi_throw_type_error, bad input surfaces as an ordinary JavaScript exception — no crashes, no mystery.

2. Strings: concat(a, b)

Strings introduce the first real N-API idiom: the two-pass copy. You can't peek at a JS string's bytes directly — you ask N-API to copy them into a buffer you own. Call napi_get_value_string_utf8 with a NULL buffer first and it reports the UTF-8 byte length; allocate; call again to copy.

static napi_value Concat(napi_env env, napi_callback_info info) {
  size_t argc = 2;
  napi_value argv[2];
  napi_get_cb_info(env, info, &argc, argv, NULL, NULL);
  if (argc < 2) THROW_AND_RETURN(env, "concat(a, b) expects two strings");
 
  /* Pass 1 — measure both strings */
  size_t len_a, len_b;
  if (napi_get_value_string_utf8(env, argv[0], NULL, 0, &len_a) != napi_ok ||
      napi_get_value_string_utf8(env, argv[1], NULL, 0, &len_b) != napi_ok) {
    THROW_AND_RETURN(env, "concat(a, b): both arguments must be strings");
  }
 
  char* buf = malloc(len_a + len_b + 1);
  if (buf == NULL) THROW_AND_RETURN(env, "concat: out of memory");
 
  /* Pass 2 — copy the bytes, second string starting where the first ended */
  napi_get_value_string_utf8(env, argv[0], buf, len_a + 1, NULL);
  napi_get_value_string_utf8(env, argv[1], buf + len_a, len_b + 1, NULL);
 
  napi_value result;
  napi_create_string_utf8(env, buf, len_a + len_b, &result);
  free(buf);  /* napi_create_string_utf8 copied it — we must clean up */
  return result;
}
concat('Hello, ', 'N-API!')  // 'Hello, N-API!'
concat('नमस्ते ', '🌍')        // 'नमस्ते 🌍' — lengths are UTF-8 *bytes*, so this just works

Two details that bite people: the lengths are bytes, not characters (that's why the emoji round-trips correctly), and the memory is yoursnapi_create_string_utf8 copies out of your buffer, so you must free() it or leak on every call.

3. Objects: isAdult(person)

Objects cross the boundary as opaque handles too. You don't get a struct — you pull properties out one at a time with napi_get_named_property, then convert each one like any standalone value. This example takes { name, age } and returns a boolean.

static napi_value IsAdult(napi_env env, napi_callback_info info) {
  size_t argc = 1;
  napi_value argv[1];
  napi_get_cb_info(env, info, &argc, argv, NULL, NULL);
  if (argc < 1) THROW_AND_RETURN(env, "isAdult(person) expects an object");
 
  napi_valuetype type;
  napi_typeof(env, argv[0], &type);
  if (type != napi_object) {
    THROW_AND_RETURN(env, "isAdult(person): argument must be an object");
  }
 
  napi_value age_value;
  if (napi_get_named_property(env, argv[0], "age", &age_value) != napi_ok) {
    THROW_AND_RETURN(env, "isAdult(person): could not read 'age'");
  }
 
  int32_t age;
  if (napi_get_value_int32(env, age_value, &age) != napi_ok) {
    THROW_AND_RETURN(env, "isAdult(person): 'age' must be a number");
  }
 
  napi_value result;
  napi_get_boolean(env, age >= 18, &result);
  return result;
}
isAdult({ name: 'Aarav', age: 21 })  // true
isAdult({ name: 'Meera', age: 12 })  // false

Notice the defensive napi_typeof check. In JS, passing a string where an object was expected fails loudly and safely; in C, skipping validation means reading garbage. Native code has to be paranoid at the boundary — validate everything, then relax.

The status check on napi_get_named_property matters for a subtler reason: reading a property can execute JavaScript — a getter can throw. If the call fails, age_value is never written, and passing an uninitialized handle to the next N-API call is undefined behavior. Checking napi_status on anything whose output you're about to use is what keeps that from ever happening.

4. Buffers: sumBuffer(buf)

This is where N-API earns its keep. napi_get_buffer_info hands you a pointer to the Buffer's actual bytes — no copying, no serialization. Getting that pointer is O(1): a 100 MB Buffer costs no more to hand over than a 10-byte one (processing the bytes, like our sum loop, is of course still O(n) — the win is that nothing is copied first). This is exactly how binary protocols, compression bindings, and my Storj uplink move data efficiently.

static napi_value SumBuffer(napi_env env, napi_callback_info info) {
  size_t argc = 1;
  napi_value argv[1];
  napi_get_cb_info(env, info, &argc, argv, NULL, NULL);
  if (argc < 1) THROW_AND_RETURN(env, "sumBuffer(buf) expects a Buffer");
 
  bool is_buffer;
  napi_is_buffer(env, argv[0], &is_buffer);
  if (!is_buffer) THROW_AND_RETURN(env, "sumBuffer(buf): argument must be a Buffer");
 
  void* data;
  size_t length;
  napi_get_buffer_info(env, argv[0], &data, &length);
 
  const uint8_t* bytes = (const uint8_t*)data;
  double sum = 0;  /* double — byte sums of large buffers overflow uint32 */
  for (size_t i = 0; i < length; i++) {
    sum += bytes[i];
  }
 
  napi_value result;
  napi_create_double(env, sum, &result);
  return result;
}
sumBuffer(Buffer.from([1, 2, 3, 4]))  // 10
sumBuffer(Buffer.alloc(1024))         // 0

The pointer is only safe to use while the Buffer's underlying memory is guaranteed to stay alive — here that's guaranteed because we finish all our work before returning, while the caller's Buffer is still referenced. Never stash the pointer for later: if native work continues after the call returns — async workers, background threads — you must hold a reference (napi_create_reference) so the Buffer can't be garbage-collected or its memory reclaimed under you. That's a topic for its own post.

Memory management: who deletes what?

Most native-addon bugs are memory bugs, and nearly all of them come from mixing up two ownership worlds that meet inside every binding:

1. JavaScript values belong to the garbage collector — never free them. Every napi_value you receive or create (strings, numbers, objects, buffers) is a handle to memory that V8 owns. There is no napi_delete_value, and calling free() on anything N-API gave you is a crash. Handles are registered in a handle scope that Node opens before your binding runs and closes when it returns — after that, the GC collects each value whenever JavaScript no longer references it. Cleanup is automatic; your job is simply to not interfere.

2. Everything you malloc belongs to you — free it on every path. N-API copies data into your buffers (napi_get_value_string_utf8) and out of them (napi_create_string_utf8). Once the copy is made, your buffer is dead weight. The subtle killer is the early-return leak:

/* LEAK — the error path returns without freeing */
char* buf = malloc(len_a + len_b + 1);
if (napi_get_value_string_utf8(env, argv[0], buf, len_a + 1, NULL) != napi_ok) {
  THROW_AND_RETURN(env, "concat: failed to read string");  /* buf leaks! */
}

Unlike a leak in a script that runs once, a leaked binding leaks on every call — a busy server bleeds memory until it falls over, and nothing in JavaScript-land will ever show you why. Audit each return between malloc and free; either release the buffer on every exit path or restructure to a single exit point.

3. Plain C values cost nothing. The double a, b in add and the int32_t age in isAdult live on the C stack and vanish when the function returns. There's nothing to delete — converting a JS number to C never allocates.

4. Creating many values in a loop? Scope them. Handles normally accumulate until your binding returns. Build a million temporary strings in one call and you're holding a million live handles — even if each was needed for only one iteration. The fix is a manual handle scope per iteration, which releases that iteration's temporaries immediately:

for (size_t i = 0; i < item_count; i++) {
  napi_handle_scope scope;
  napi_open_handle_scope(env, &scope);
 
  /* ...create temporary napi_values for item i... */
 
  napi_close_handle_scope(env, scope);  /* iteration's handles released here */
}

5. Lifetimes that outlive the call need explicit management. Two tools cover the advanced cases: napi_create_reference pins a JS value so the GC can't collect it while your native code still needs it (you must call napi_delete_reference later — a leaked reference is a leaked object). And going the other direction, napi_create_external / napi_wrap hand a C pointer to JavaScript with a finalizer: a callback the GC invokes when the JS object dies, which is where you free() the native side. That pairing is how real bindings — including storj-uplink-nodejs — tie the lifetime of C handles to JS objects without ever exposing a manual close() footgun.

The rule of thumb that covers 90% of it: if you allocated it, free it before you return; if N-API gave it to you, leave it alone.

Wiring it all up

The last piece registers the four functions on module.exports. This runs once, when require() loads the compiled binary:

static napi_value Init(napi_env env, napi_value exports) {
  napi_property_descriptor props[] = {
    {"add",       NULL, Add,       NULL, NULL, NULL, napi_default, NULL},
    {"concat",    NULL, Concat,    NULL, NULL, NULL, napi_default, NULL},
    {"isAdult",   NULL, IsAdult,   NULL, NULL, NULL, napi_default, NULL},
    {"sumBuffer", NULL, SumBuffer, NULL, NULL, NULL, napi_default, NULL},
  };
  napi_define_properties(env, exports, sizeof(props) / sizeof(props[0]), props);
  return exports;
}
 
NAPI_MODULE(NODE_GYP_MODULE_NAME, Init)

Build and run:

npm install && npm run build && npm test
add(2, 3)                       = 5
concat('Hello, ', 'N-API!')     = Hello, N-API!
isAdult({ age: 21 })            = true
sumBuffer(Buffer [1,2,3,4])     = 10
 
All assertions passed ✔

Where to go from here

These four patterns — numbers, strings, objects, buffers — cover most of what a real binding does at the boundary. The next steps in a production addon are async work (napi_create_async_work, so slow native calls don't block the event loop), object lifetimes (napi_ref for values that must outlive a call), and prebuilt binaries so your users never need a compiler. Those are exactly the problems storj-uplink-nodejs solves, if you want to read a full-scale example.


All code from this post, ready to clone and run: github.com/shivamkumar99/n-api-example