#3351·napi-rs

RFC: Pluggable custom async runtime backend

Author: BrooooooklynCreated Jun 26, 2026Updated Jun 26, 2026
LabelsenhancementRFC

Summary

Add a feature-gated, pluggable async runtime backend to napi-rs.

Today napi-rs's ergonomic async bindings are coupled to the Tokio runtime. The existing create_custom_tokio_runtime API allows consumers to tune Tokio, but it does not allow a different executor, a genuinely threadless executor, or a runtime that shares scheduling resources with a downstream application's CPU work.

The proposal is to preserve the current Tokio implementation and behavior behind the existing Tokio feature, while adding a separate feature that allows an addon to register a custom async runtime before its first async N-API call.

This expands on #1211. It is also different from the local !Send future work in #2152: the primary goal here is backend replacement, lifecycle control, threadless WebAssembly support, and scheduler/resource ownership. Supporting !Send futures can remain a separate concern.

Motivating downstream: Rolldown

Rolldown exposes a large asynchronous N-API surface and is also compiled to WebAssembly. It currently exposes several problems that cannot be solved by tuning a custom Tokio runtime alone.

1. Threadless WebAssembly hosts

Rolldown can compile to WASI, but its current async path requires threaded WASI and JavaScript workers:

  • Tokio uses a threaded runtime for the current WASI build.
  • napi-rs/emnapi's generated loader creates shared WebAssembly memory.
  • Async work and Rust threads require worker_threads in Node.js or Web Workers in browsers.

That build cannot run in environments such as Cloudflare Workers, where an addon cannot create worker_threads or Web Workers.

Rolldown needs a real current-thread flavor which:

  • compiles for wasm32-wasip1, not only wasm32-wasip1-threads;
  • uses unshared WebAssembly memory;
  • does not import or construct a Worker;
  • does not use std::thread::spawn;
  • does not park with Atomics.wait;
  • can still drive Rust futures and resolve JavaScript promises.

The runtime flavor should be configurable at the top level, before the first async binding call:

typescript
configureAsyncRuntime({
  flavor: 'CurrentThread', // or 'MultiThread'
  workerThreads: 12,
  maxBlockingTasks: 12,
})

The concrete configuration API belongs to the addon, but napi-rs needs to make runtime registration possible.

2. Oversubscribed thread pools

Rolldown has historically used independent execution resources for different categories of work:

  • Tokio async workers for module graph tasks and plugin futures;
  • Tokio's blocking pool for synchronous filesystem reads;
  • Rayon workers for parse/link/generate CPU parallelism;
  • a small number of directly spawned threads for specialized tasks.

This creates more threads than the hardware can use effectively, especially when several Rolldown processes run concurrently. It also makes tuning one pool dependent on the behavior of the other pools.

Relevant Rolldown background:

  • rolldown#6270 moved blocking file reads off async workers and produced a large performance improvement.
  • rolldown#6272 increased Tokio workers because substantial CPU work runs inside async module tasks.
  • rolldown#9086 added a worker count override after concurrent Rolldown processes oversubscribed machines.
  • rolldown#9942 demonstrated why parking a browser main thread is invalid in threaded WebAssembly.

A custom backend lets a downstream application own this policy instead of forcing every type of work through a napi-rs-owned Tokio runtime.

3. CPU utilization during bundling

Rolldown's async module tasks and Rayon stages are currently scheduled independently. During a bundle, workers in one pool may be idle while runnable work exists in another pool.

For this workload, a custom backend can schedule async future polling and CPU-parallel work on the same work-stealing pool. Blocking filesystem jobs can also be bounded within that fixed pool. This keeps cores busy while avoiding another async and blocking thread pool.

This is a downstream scheduling choice. napi-rs should provide the hook, not prescribe Rayon or any other executor.

Proposed napi-rs contract

A possible minimal interface is:

rust
pub trait AsyncRuntimeGuard {}

pub trait AsyncRuntime: Send + Sync + 'static {
  fn spawn(
    &self,
    future: Pin<Box<dyn Future<Output = ()> + Send + 'static>>,
  );

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

  fn enter(&self) -> Box<dyn AsyncRuntimeGuard + '_> {
    Box::new(())
  }

  fn start(&self) {}

  fn shutdown(&self) {}
}

pub fn create_custom_async_runtime(runtime: impl AsyncRuntime);

The exact API is open for discussion. The important ownership boundaries are:

  • napi-rs continues to own JavaScript Promise/Deferred creation and resolution;
  • napi-rs continues to catch async panics and reject promises consistently;
  • the registered backend owns polling, scheduling, runtime entry, startup, and shutdown;
  • Env::spawn_future, generated async fn bindings, async iterators, and internal future execution all use the selected backend;
  • existing Tokio users keep their current behavior without enabling the new feature.

Feature and compatibility requirements

Suggested feature behavior:

  • tokio_rt: current implementation and public behavior;
  • async-runtime: enable runtime registration and backend-neutral async execution.

Breaking API changes are acceptable inside the new feature if required, but the existing Tokio path should remain source-compatible.

One important Cargo detail is feature unification. Large addons may depend on other N-API crates which enable tokio_rt. For example, Rolldown re-exports OXC N-API crates. A registered custom backend must take precedence when async-runtime and tokio_rt are both enabled, otherwise downstream crates cannot reliably select the custom executor.

Some existing public APIs expose Tokio-specific types, such as Tokio join handles. The RFC should decide whether the custom feature:

  • exposes an opaque backend-neutral handle;
  • exposes detached spawning only;
  • or provides separate Tokio-specific and backend-neutral functions.

WASI and CLI support

Runtime registration alone is not sufficient for the threadless use case. The napi-rs build and generated JavaScript loader should also support a non-threaded WASI target:

  • accept wasm32-wasip1;
  • link emnapi's non-atomic libemnapi-basic.a;
  • omit -pthread and threaded exports;
  • create unshared WebAssembly.Memory;
  • set asyncWorkPoolSize: 0;
  • omit Worker imports, worker scripts, and onCreateWorker.

This should coexist with the current wasm32-wasip1-threads output.

Unshared memory growth detaches old JavaScript ArrayBuffer views, so the threadless path also needs regression coverage for async callbacks and threadsafe-function dispatch across memory.grow.

Blocking work

Rolldown needs bounded blocking filesystem work, but this does not necessarily mean napi-rs must define a generic spawn_blocking method in the first version of the trait.

Questions for the RFC:

  • Should blocking work be an optional runtime capability?
  • Should napi-rs's public spawn_blocking delegate to the registered backend?
  • Is it sufficient for downstream applications to own blocking work while napi-rs only drives binding futures?
  • How should cancellation and panic propagation be represented without exposing Tokio types?

Lifecycle requirements

The existing runtime handles Node environments, worker threads, Electron reloads, cleanup hooks, and manual WASI shutdown. A custom backend needs the same lifecycle integration:

  • register before the first async binding is executed;
  • start lazily or during module registration;
  • enter the runtime when generated callbacks require runtime context;
  • shut down when the N-API environment is destroyed;
  • support restart where napi-rs currently supports runtime recreation;
  • avoid retaining an environment after cleanup.

It should be explicit whether registration is process-global, addon-global, or per N-API environment.

Non-goals

  • Replacing Tokio as the default runtime.
  • Bundling a specific third-party executor into napi-rs.
  • Solving all !Send JavaScript value lifetime problems from async_local.
  • Exposing libuv ABI-dependent APIs.
  • Making synchronous filesystem APIs non-blocking automatically.

Validation and acceptance criteria

The implementation should be validated with more than wall-clock time:

  • total process and addon-owned thread counts;
  • user and system CPU time;
  • voluntary and involuntary context switches;
  • maximum RSS and allocation behavior;
  • runnable queue depth and scheduler polls;
  • CPU idle time/core utilization during a bundle;
  • native wall time on small and large projects;
  • threadless WASI correctness with repeated async JavaScript callbacks and memory growth.

The rolldown-benchmarks fixtures are a useful downstream workload.

A downstream prototype using one shared 12-worker pool instead of separate Tokio async/blocking and Rayon pools showed:

  • 12 Rolldown scheduler threads instead of roughly 34;
  • 19 total process threads instead of 41 in a late-build sample;
  • no regression and approximately 1-3% faster wall time across representative fixtures;
  • lower maximum RSS and fewer retired instructions on the largest fixture;
  • successful wasm32-wasip1 builds without Workers or shared memory.

These numbers are machine-specific, but they indicate that the abstraction is practical and worth supporting in napi-rs.

Open questions

  1. What should the backend-neutral spawn/handle API look like?
  2. Should blocking work be part of the first runtime trait?
  3. Should registration and runtime state be global or per N-API environment?
  4. How should custom backends report unsupported capabilities such as timers or networking?
  5. Should the existing Tokio-named internal functions be renamed with compatibility aliases?
  6. Should wasm32-wasip1 be a first-class napi-rs target or an opt-in CLI configuration initially?

Related

  • #1211
  • #2152