Skip to content

Async and concurrency

The right abstraction depends on where work must run and whether Rust needs to call JavaScript while it is running. Start with the smallest abstraction that matches the work; moving a synchronous function to another thread does not make its dependencies safe to use there.

Decision table

Need Use Work runs on JavaScript result
Fast conversion or computation Ordinary #[napi] fn JavaScript thread Immediate value or throw
Rust async I/O or async ecosystem #[napi] async fn NAPI-RS Tokio runtime Promise<T>
Blocking/CPU work using Node's worker pool AsyncTask<T> libuv thread pool; resolve returns to JS thread Promise<T>
Call a JS function from an OS/Tokio thread ThreadsafeFunction Producer thread, callback on JS thread Callback or awaited return
Deliver a sequence lazily iterator or async iterator Pull-based for...of / for await...of
Stream bytes with Web Streams ReadableStream / WritableStream Tokio plus JS stream callbacks Web Streams API

Two rules apply to every row:

  1. Only the JavaScript thread may use Env or raw napi_value handles.
  2. Data crossing a thread or await boundary must be owned for long enough; borrowed JavaScript values are function-scoped.

See Understanding lifetime before moving buffers, objects, or class instances into background work.

Tokio async fn

Enable async (which enables the NAPI-RS Tokio runtime) and only the Tokio features your crate uses:

Cargo.toml
toml
[dependencies]
napi = { version = "3", features = ["async"] }
napi-derive = "3"
tokio = { version = "1", features = ["fs", "time"] }
src/lib.rs
rust
use napi::bindgen_prelude::*;
use napi_derive::napi;

#[napi]
pub async fn read_config(path: String) -> Result<Buffer> {
  Ok(tokio::fs::read(path).await?.into())
}

The future and its output cross threads, so they must be Send + 'static. Prefer owned inputs such as String, Buffer, and owned typed arrays. Do not hold JsString<'_>, Object<'_>, or an Env across an await point.

async fn is appropriate for async I/O. A long synchronous calculation inside it still occupies a Tokio worker thread; use tokio::task::spawn_blocking or an AsyncTask for blocking work.

Cancellation is not automatic

Dropping the JavaScript Promise does not cancel its Rust future. Design a cancellation protocol for long-running work:

  • accept an explicit cancellation handle or operation ID;
  • bridge cancellation to an atomic flag, channel, or library cancellation token owned by Rust;
  • stop creating JavaScript work after cancellation;
  • await or abort every spawned Tokio JoinHandle during owner shutdown.

Detached work spawned with napi::tokio::spawn must not outlive the environment or the Rust/JavaScript resources it uses. Keep its JoinHandle in an owning class and abort or await it in your shutdown path.

AsyncTask and the libuv worker pool

Use AsyncTask for bounded blocking work that fits Node's shared libuv thread pool. Task::compute runs off the JavaScript thread; resolve, reject, and finally run after completion where an Env is available.

src/lib.rs
rust
use napi::bindgen_prelude::*;
use napi_derive::napi;

pub struct HashFile {
  path: String,
}

#[napi]
impl Task for HashFile {
  type Output = Vec<u8>;
  type JsValue = Buffer;

  fn compute(&mut self) -> Result<Self::Output> {
    // Blocking file and CPU work is allowed here. Do not call JavaScript.
    Ok(std::fs::read(&self.path)?)
  }

  fn resolve(&mut self, _env: Env, bytes: Self::Output) -> Result<Self::JsValue> {
    Ok(bytes.into())
  }
}

#[napi]
pub fn hash_file(path: String) -> AsyncTask<HashFile> {
  AsyncTask::new(HashFile { path })
}

AsyncTask::with_signal accepts an AbortSignal, but Node-API can cancel only work that has not started. Once compute is running, calling AbortController.abort() does not interrupt it. If running work must stop, combine AbortSignal::on_abort with your own cooperative flag/channel and make compute check it.

The libuv pool is shared with Node filesystem, DNS, crypto, and other native work. Flooding it with long CPU tasks can delay unrelated application work. Bound concurrency at the JavaScript API or use a dedicated Rust pool when that is part of your performance design.

ThreadsafeFunction

Use a ThreadsafeFunction when a Rust thread must schedule a JavaScript callback. The producer sends owned Rust data; the conversion and callback execute on the owning JavaScript environment.

Choose its queue behavior deliberately:

  • NonBlocking returns immediately. With a bounded queue, handle Status::QueueFull as backpressure instead of dropping data silently.
  • Blocking waits for queue space. Never use it from the JavaScript thread and avoid it in shutdown paths where the event loop may no longer drain.
  • A queue size of 0 is unbounded. It avoids QueueFull but can turn a slow callback into unbounded memory growth.
  • A strong ThreadsafeFunction keeps the event loop alive. Build with .weak::<true>() when pending callbacks are not a reason to keep the process running.

Drop all clones to release a ThreadsafeFunction. Calling abort closes it immediately; later calls report Status::Closing.

JavaScript errors and return values

callee_handled::<true>() uses the Node callback convention: Rust calls the ThreadsafeFunction with a Result, and JavaScript receives an error-first callback. With false, the Rust call accepts only the value and JavaScript receives no error parameter; handle recoverable native failures before calling it. Use call_async when Rust must await the callback result, and use the variant that catches JavaScript-thrown values when those failures are recoverable.

Never let a JavaScript exception cross an FFI callback as an unchecked Rust panic. Return or explicitly handle the napi::Error.

AsyncLocalStorage and request context

A ThreadsafeFunction is registered as its own Node async resource. Do not assume a callback scheduled later from a Rust thread inherits the AsyncLocalStorage store that happened to be active when the native API was called. If context is part of correctness, pass a request ID or context object as owned data and restore it in JavaScript (for example with an AsyncResource) rather than relying on ambient state.

Promise continuations may preserve JavaScript async context differently from a ThreadsafeFunction callback. Test the exact API/runtime combination you ship.

Iterators and streams

Use an iterator when JavaScript should pull one value at a time. Use an async iterator when producing the next value is asynchronous. Their pull model is usually easier to cancel and bound than pushing every item through an unbounded ThreadsafeFunction queue.

Use the web_stream feature when consumers require the Web Streams API:

Cargo.toml
toml
[dependencies]
napi = { version = "3", features = ["web_stream"] }

Web Streams are best established for byte-oriented data. Structured Rust objects in ReadableStream have an unresolved behavior report (napi-rs#2826); add a runtime test before exposing structured chunks as a supported API.

Whichever abstraction you choose, define what happens when the consumer stops:

  • cancel the producer when return(), cancel(), or abort() is called;
  • release JavaScript references and queue senders;
  • ensure a blocked producer wakes during shutdown;
  • decide whether buffered values are delivered or discarded.

Runtime lifecycle

With tokio_rt, NAPI-RS creates a Tokio runtime and starts it when the native module is registered. On native Node targets it is shut down after the last Node-API environment using the module exits, and it can be started again for an Electron renderer reload.

Register environment-specific resources for each environment. Do not cache one Env, JavaScript reference, class constructor, or ThreadsafeFunction globally and reuse it from the main thread in a worker isolate.

Custom Tokio runtime

Install a custom runtime during module initialization, before async exports use the default runtime:

src/lib.rs
rust
use napi::create_custom_tokio_runtime;

#[napi_derive::module_init]
fn init() {
  let runtime = tokio::runtime::Builder::new_multi_thread()
    .worker_threads(4)
    .enable_all()
    .build();
  match runtime {
    Ok(runtime) => create_custom_tokio_runtime(runtime),
    Err(err) => eprintln!("failed to create custom Tokio runtime: {err}"),
  }
}

WARNING

A custom runtime instance is currently consumed once. After shutdown_async_runtime() followed by start_async_runtime(), NAPI-RS falls back to its default runtime rather than recreating the custom configuration. This is an open product limitation, not a supported restart contract (napi-rs#3251).

WASI has different runtime teardown constraints. If your WASI API starts the runtime explicitly or exposes a shutdown function, test repeated startup and shutdown in the actual WASI host. See WebAssembly.

Worker shutdown protocol

Do not make abrupt termination the normal cancellation mechanism for native work. A resilient worker protocol is:

  1. The parent sends stop.
  2. The worker stops accepting native calls.
  3. Rust cancellation tokens are triggered.
  4. The worker awaits active promises and drops ThreadsafeFunction producers.
  5. The worker replies stopped and closes its message port.
  6. The parent uses worker.terminate() only after a deadline.

Node worker lifecycle and Bun worker lifecycle are not interchangeable. Abrupt termination during an async native operation still has an open Bun crash report (napi-rs#2938). Mark such a runtime as unsupported for that API, or keep the graceful protocol mandatory, until your own stress test proves otherwise.

Review checklist

Before shipping an async export, answer these questions in its documentation and tests:

  • Which pool/runtime/thread performs the work?
  • Can it access JavaScript, and only on the correct environment?
  • What owns every value across await and thread boundaries?
  • Is the queue bounded, and what happens under backpressure?
  • How does the caller cancel queued and already-running work?
  • What keeps the Node event loop alive?
  • What happens during worker termination, Electron reload, and process exit?
  • Are JavaScript exceptions and Rust panics converted into defined failures?
  • Is ambient async context required, or is context passed explicitly?