Skip to content

Custom async runtime

Exported Rust async fns normally run on the Tokio runtime that NAPI-RS manages when the async / tokio_rt feature is enabled (see async fn). The async-runtime Cargo feature exposes the layer underneath: a service-provider interface (SPI) that lets you back every generated async fn, Env::spawn_future, and AsyncBlock with your own scheduler instead of Tokio.

You want this when:

  • your addon runs where Tokio does not fit — a threadless wasm32-wasip1 build, workerd / edge isolates, or the browser;
  • you already own an executor (a game loop, an embedded runtime, a company-wide scheduler) and do not want a second runtime in the process;
  • you want a tokio-free binary: a pure async-runtime build links no Tokio at all.
Cargo.toml
toml
[dependencies]
napi = { version = "3", default-features = false, features = ["napi4", "async-runtime"] }
napi-derive = "3"

The feature only enables napi4; it deliberately does not pull in Tokio. The three build shapes:

Features Behavior
async-runtime only No Tokio is linked. Synchronous exports work without any registration; a runtime-backed operation (for example a generated #[napi] async fn) rejects its promise with a missing-backend error until a backend is registered.
async-runtime + tokio_rt Generated async fns follow the selection: the custom backend if one was registered in time, otherwise Tokio. Selecting a custom backend never constructs Tokio. The compatibility helpers spawn, spawn_blocking, block_on, and within_runtime_if_available stay Tokio-backed with unchanged signatures.
noop + async-runtime The SPI types exist so code compiles, but no backend can be installed; registration retires the backend and reports InvalidArg.

The AsyncRuntime SPI

A backend is a single unsafe impl AsyncRuntime, registered once per addon image:

rust
pub unsafe trait AsyncRuntime: Send + Sync + 'static {
  fn spawn(
    &self,
    task: AsyncRuntimeTask,
  ) -> std::result::Result<(), AsyncRuntimeRejection<AsyncRuntimeTask>>;

  fn block_on(&self, future: Pin<&mut dyn Future<Output = ()>>) -> Result<()>;

  fn enter(&self) -> Result<Box<dyn AsyncRuntimeGuard + '_>> { ... } // default: no-op guard

  fn start(&self) -> Result<()> { ... } // default: no-op

  fn shutdown(&self) -> Result<()>;

  fn spawn_blocking(
    &self,
    work: Box<dyn FnOnce() + Send + 'static>,
  ) -> std::result::Result<(), AsyncRuntimeRejection<Box<dyn FnOnce() + Send + 'static>>> { ... } // default: declines
}

The pieces you interact with:

  • AsyncRuntimeTask — the opaque unit of work napi hands you (the future behind a generated async fn plus the promise-settling machinery). It is Future<Output = ()> + Send + 'static. A conforming backend has exactly three options: poll it to completion, drop it (dropping before completion cancels the task and rejects the associated JavaScript promise), or hand it back untouched in an AsyncRuntimeRejection when declining the submission. Never mem::forget an accepted task, and never bypass its Drop.
  • AsyncRuntimeRejection — carries declined work back to napi together with a diagnostic Error, which surfaces as the promise rejection.
  • start / shutdown — the lifecycle hooks. Keep a freshly constructed backend dormant: create threads and queues in start, release them in shutdown. napi calls start when the first live Node environment begins (worker isolates and Electron renderer reloads can take the environment count from zero to one repeatedly, so it must be idempotent), and shutdown when its accounting observes the last environment exit. Embedders can also call shutdown_async_runtime explicitly.
  • enter — establishes an ambient runtime context for the calling thread (the Tokio equivalent of Runtime::enter). The default no-op guard is correct for backends that do not need one.
  • spawn_blocking — an optional blocking-capable lane. The default implementation declines; on threadless targets there is no blocking lane, so declining is the right behavior there.

A minimal backend — one OS thread per task, with every worker joined before shutdown returns so nothing can outlive the native image — looks like this. It depends on futures (for futures::executor::block_on) in addition to napi:

lib.rs
rust
use std::{
  future::Future,
  pin::Pin,
  sync::Mutex,
  thread::JoinHandle,
};

use napi::bindgen_prelude::{
  register_async_runtime, AsyncRuntime, AsyncRuntimeRejection, AsyncRuntimeTask, Result,
};

#[derive(Default)]
struct SimpleRuntime {
  // Every spawned worker, so `shutdown` can join them all.
  workers: Mutex<Vec<JoinHandle<()>>>,
}

// SAFETY: `shutdown` joins every worker thread before returning, so no
// backend-owned thread or task can execute addon code after the native image
// starts unloading.
unsafe impl AsyncRuntime for SimpleRuntime {
  fn spawn(
    &self,
    task: AsyncRuntimeTask,
  ) -> std::result::Result<(), AsyncRuntimeRejection<AsyncRuntimeTask>> {
    let handle = std::thread::spawn(move || futures::executor::block_on(task));
    self.workers.lock().unwrap().push(handle);
    Ok(())
  }

  fn block_on(&self, future: Pin<&mut dyn Future<Output = ()>>) -> Result<()> {
    futures::executor::block_on(future);
    Ok(())
  }

  fn shutdown(&self) -> Result<()> {
    for handle in self.workers.lock().unwrap().drain(..) {
      let _ = handle.join();
    }
    Ok(())
  }
}

#[napi_derive::module_init]
fn init() {
  register_async_runtime(SimpleRuntime::default());
}

A real backend also needs a spawn_blocking lane, bounded queues, and cancellation on drop. The runnable, fully-documented implementation is the examples/custom-async-runtime crate — including the threadless-WASI, workerd, and browser wiring.

Registering from #[module_init]

Registration is once per linked addon image, first-writer-wins, and must happen before the registration window closes — when napi begins activating the first Node-API environment, or earlier when a runtime-backed operation commits a backend choice. That is why the natural place is a #[module_init] hook, which runs as a library constructor before napi owns any environment.

Two entry points:

  • register_async_runtime(runtime) — the infallible form for #[module_init]. It never panics (a panic in a library constructor would abort the process); a duplicate or late registration is recorded, and every later runtime-backed operation surfaces it by rejecting its promise.
  • try_register_async_runtime(runtime) -> Result<()> — the fallible form that returns the error directly. A rejected backend is safely retired through its own shutdown hook either way.

shutdown_async_runtime

shutdown_async_runtime() invokes the registered backend's shutdown hook — its sole resource-release and quiescence point. A backend's Drop implementation is not guaranteed to run, so everything must be released in shutdown. On WebAssembly targets the runtime is not shut down automatically, so call shutdown_async_runtime explicitly there (for example between test runs).

WARNING

After shutdown returns, Node may unload the addon's native image immediately. No backend-owned thread, task, closure, destructor, cancellation callback, or retained Waker may execute code from that image afterwards. A backend that cannot prove those references inert must keep the image loaded itself rather than return from shutdown.

WARNING

The panic containment napi performs around backend hooks and task polls requires a panic = "unwind" build. On panic = "abort" targets — including Rust's shipped wasm32-wasip1 and wasm32-wasip1-threads targets — a panicking async function traps or aborts before its JavaScript promise is settled.

The napi-async-runtime crate

You usually do not need to write a backend by hand. napi-async-runtime (crates.io, v0.2.x) is the published, batteries-included implementation: a tokio-free async/CPU/blocking scheduler plus an optional adapter that registers it as the addon's AsyncRuntime backend.

Cargo.toml
toml
[dependencies]
napi = { version = "3", default-features = false, features = ["napi4", "async-runtime"] }
napi-derive = "3"
napi-async-runtime = "0.2"

Install it from your own #[module_init] — the crate deliberately ships no module_init of its own, so the host stays in charge of configuration resolution order:

lib.rs
rust
#[napi_derive::module_init]
fn init() {
  // Resolve your own configuration (env vars, defaults) FIRST; the scheduler
  // never reads the environment itself.
  napi_async_runtime::install(napi_async_runtime::RuntimeOptions::default())
    .expect("failed to install the shared async runtime");
}

#[napi]
pub async fn sleep_then_add(a: u32, b: u32, sleep_ms: u32) -> u32 {
  napi_async_runtime::sleep_until(
    std::time::Instant::now() + std::time::Duration::from_millis(u64::from(sleep_ms)),
  )
  .await;
  a + b
}

What the crate provides:

  • Scheduler API at the crate root (always compiled, napi-free): spawn / try_spawn / spawn_detached, spawn_blocking, block_on, sleep_until, configure / configure_partial / configured_options, start / shutdown, and metrics / reset_metrics returning a RuntimeMetricsSnapshot.
  • Two flavors. MultiThread (native only) runs futures on a Rayon-backed worker pool; CurrentThread — the only flavor on WebAssembly — never creates threads and instead publishes host turns through registered CurrentThreadTaskDriver / TimerDriver host drivers. The blocking-lane cap is worker_threads - 1, so one execution lane always stays available for runnable futures and timers.
  • The install() adapter (default napi feature), which configures the scheduler and calls register_async_runtime for you, plus the JavaScript-facing host protocol exports (registerCurrentThreadTaskHost, registerTimerHost, getAsyncRuntimeMetrics, …). The matching JavaScript host installers for CurrentThread builds live in the @napi-rs/async-runtime npm package.
  • Threadless-wasm safety: on wasm32-wasip1 / wasm32-unknown-unknown (no threads, no Atomics.wait), a block_on park that provably can never be woken fails loudly with a typed BlockOnDeadlock panic instead of hanging the JS event loop.

The end-to-end consumer to copy from is examples/shared-async-runtime; the hand-rolled reference backend is examples/custom-async-runtime. See the Examples page for the full list.

Last updated on
LongYinan