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:
- Only the JavaScript thread may use
Envor rawnapi_valuehandles. - Data crossing a thread or
awaitboundary 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:
[dependencies]
napi = { version = "3", features = ["async"] }
napi-derive = "3"
tokio = { version = "1", features = ["fs", "time"] }
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
JoinHandleduring 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.
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:
NonBlockingreturns immediately. With a bounded queue, handleStatus::QueueFullas backpressure instead of dropping data silently.Blockingwaits 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
0is unbounded. It avoidsQueueFullbut 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:
[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(), orabort()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:
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:
- The parent sends
stop. - The worker stops accepting native calls.
- Rust cancellation tokens are triggered.
- The worker awaits active promises and drops ThreadsafeFunction producers.
- The worker replies
stoppedand closes its message port. - 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
awaitand 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?