Introduction

Node.js N-API native addons use napi_async_context to track asynchronous operations for proper async hooks, diagnostic reporting, and context propagation. When an async scope is opened with napi_open_async_context() but not closed with napi_close_async_context(), the async context leaks, causing memory growth, incorrect async hook behavior, and diagnostic data corruption. This error is particularly insidious because it does not crash the process immediately -- instead, it causes gradual memory growth and misleading performance profiles that are hard to attribute to the native addon.

Symptoms

Memory grows when using native addon:

```bash # Without native addon $ node -e "setInterval(() => {}, 1000)" RSS after 1 hour: 45MB

# With native addon that leaks async context $ node -e "const addon = require('./build/Release/addon'); addon.start()" RSS after 1 hour: 350MB ```

Or async hooks report incorrect data:

```javascript const { executionAsyncId } = require('async_hooks');

console.log(executionAsyncId()); // Returns stale async ID from the native addon's leaked context ```

Or diagnostic tools show anomalous async resource count:

```javascript const { createHook } = require('async_hooks'); let asyncResourceCount = 0;

createHook({ init(asyncId, type) { asyncResourceCount++; }, destroy(asyncId) { asyncResourceCount--; }, }).enable();

// asyncResourceCount keeps growing and never decreases ```

Common Causes

  • napi_close_async_context not called: Async context opened but never closed
  • Error path skips cleanup: Exception thrown between open and close
  • Async callback not matched with close: One open per callback, but close called fewer times
  • Addon unload does not clean up: napi_close_async_context not called in cleanup hook
  • Multiple async operations share one context: Each operation needs its own context
  • N-API version mismatch: Different N-API versions handle async context differently

Step-by-Step Fix

Step 1: Properly open and close async context

```c #include <node_api.h>

typedef struct { napi_async_context async_context; napi_ref callback_ref; napi_env env; // ... other data } addon_context;

napi_value StartAsyncWork(napi_env env, napi_callback_info info) { addon_context* context = malloc(sizeof(addon_context)); context->env = env;

// Open async context - MUST be matched with close napi_status status = napi_open_async_context( env, "my_addon_async_work", // Resource name NAPI_UNDEFINED_ID, // Resource ID (auto-generated) &context->async_context );

if (status != napi_ok) { napi_throw_error(env, NULL, "Failed to open async context"); free(context); return NULL; }

// Do async work... // When done, ALWAYS close the context napi_close_async_context(env, context->async_context); free(context);

return NULL; } ```

Step 2: Use cleanup hook for guaranteed cleanup

```c void cleanup_hook(void* data, napi_env env, void* finalize_hint) { addon_context* context = (addon_context*)data;

// Close async context if still open if (context->async_context != NULL) { napi_close_async_context(context->env, context->async_context); context->async_context = NULL; }

// Delete callback reference if (context->callback_ref != NULL) { napi_delete_reference(context->env, context->callback_ref); }

free(context); }

napi_value Init(napi_env env, napi_value exports) { addon_context* context = malloc(sizeof(addon_context)); context->async_context = NULL; context->callback_ref = NULL; context->env = env;

// Register cleanup hook napi_add_env_cleanup_hook(env, cleanup_hook, context);

// ... rest of initialization return exports; }

NAPI_MODULE(NODE_GYP_MODULE_NAME, Init) ```

Step 3: Track async operations with a counter

```c typedef struct { napi_env env; int active_async_ops; napi_async_context* contexts; // Array of active contexts uv_mutex_t mutex; } addon_state;

void async_work_complete(napi_env env, void* data) { addon_state* state = (addon_state*)data;

uv_mutex_lock(&state->mutex);

// Close the async context for this operation if (state->active_async_ops > 0) { state->active_async_ops--; napi_close_async_context(env, state->contexts[state->active_async_ops]); }

uv_mutex_unlock(&state->mutex); } ```

Prevention

  • Always pair napi_open_async_context() with napi_close_async_context()
  • Register a cleanup hook with napi_add_env_cleanup_hook() for guaranteed cleanup
  • Use a counter to track active async operations and verify it reaches zero
  • Add assertions that verify async context is not NULL before closing
  • Test native addons with --async-hooks flags to detect context leaks
  • Use Valgrind or AddressSanitizer to detect memory leaks in native code
  • Document the async lifecycle in the addon's README for contributors