Understanding Lifetime
Interoperability between the Rust lifetime system and JavaScript memory management is tricky. In most cases, you can't keep using a JavaScript handle after the Rust function returns. However, there are a bunch of APIs in Node-API that can extend the lifetime of the JavaScript values. NAPI-RS uses these APIs to align the lifetime of the JavaScript values with the Rust lifetime system as much as possible.
During a Node-API function call, JavaScript value handles are normally valid only until the call's handle scope closes; see Object Lifetime Management.
As Node-API calls are made, handles to objects in the heap for the underlying VM may be returned as napi_values. These handles must hold the objects 'live' until they are no longer required by the native code, otherwise the objects could be collected before the native code was finished using them.
As object handles are returned they are associated with a 'scope'. The lifespan for the default scope is tied to the lifespan of the native method call. The result is that, by default, handles remain valid and the objects associated with these handles will be held live for the lifespan of the native method call.
Lifetime of owned primitive conversions
When JavaScript primitives are accepted as owned Rust values such as bool, a
Rust integer or float, or String, NAPI-RS copies their value into Rust-owned
data. That Rust data is not tied to a Node-API handle scope. This is different
from accepting a handle wrapper such as JsString<'env> or JsNumber<'env>.
Lifetime of JsValue
Handle wrappers such as JsNumber<'env> and JsString<'env> refer to a
napi_value in the current environment's handle scope. You can read an owned
Rust value from them—for example, a JsNumber can be read as f64 or
u32—but the wrapper itself remains scoped.
use napi::{bindgen_prelude::{Either, Result}, JsNumber};
use napi_derive::napi;
#[napi]
pub fn read_number(a: JsNumber) -> Result<Either<f64, u32>> {
let input_u32 = a.get_uint32()?;
let input_f64 = a.get_double()?;
if input_u32 as f64 == input_f64 {
Ok(Either::B(input_u32))
} else {
Ok(Either::A(input_f64))
}
}
The returned numbers in this example are owned Rust values. The JsNumber
handle is not: its lifetime prevents it from being used after the native call's
scope closes. The same distinction applies to strings: String contains a
copy, while JsString<'env> is a scoped JavaScript handle. In most signatures,
Rust infers the scope lifetime for you.
Lifetime of class instances
In #[napi] class, the instance is created by the Rust side and sent the ownership to the JavaScript side:
use std::sync::Arc;
use napi_derive::napi;
#[napi]
pub struct Engine {
inner: Arc<()>,
}
#[napi]
impl Engine {
#[napi(constructor)]
pub fn new() -> Self {
Self { inner: Arc::new(()) }
}
}
const engine = new Engine()
In this case, the Engine instance is created in the constructor and returned to JavaScript.
Unlike JsNumber or JsString, the Engine holds the Rust struct under the hood, so if it's passed back from the JavaScript side, you can get the &Engine or &mut Engine directly.
Class instances Lifetime Flowchart
The following flowchart illustrates the lifetime of a NAPI-RS struct instance lifetime:
flowchart
A[JavaScript calls new Engine]
B[Rust constructor creates Engine]
C[Box Engine and attach it with napi_wrap]
D[Return the JavaScript Engine instance]
F[Pass back to Rust]
G[napi_unwrap]
H[Get &Engine or &mut Engine]
I[JavaScript GC]
J[napi_finalize_cb]
K[Delete Engine struct]
A --> B
B --> C
C --> D
D --> F
F --> G
G --> H
D --> I
I --> J
J --> K
Lifetime of Buffer and TypedArray
Buffer and the concrete owned typed-array types (Uint8Array,
Int32Array, and so on) can outlive a native call. Their wrappers keep the
backing store alive while Rust holds them. The scoped BufferSlice<'env>,
typed-array slice types, and TypedArray<'env> instead borrow a handle from the
current environment scope.
NAPI-RS provides two categories of buffer types with different lifetime characteristics:
Owned Types - Cross-Thread Lifetime
For a JavaScript-origin value, converting to an owned Buffer, Uint8Array,
and similar type creates a napi_ref:
- The reference keeps the JavaScript object and its backing data alive until the Rust wrapper is dropped
- The wrapper can be moved across async boundaries and threads
- Dropping the wrapper releases Rust's reference; JavaScript may still retain the same object independently
use napi::bindgen_prelude::*;
use napi_derive::napi;
#[napi]
pub fn print_buffer(buffer: Buffer) {
// Make a Rust-owned copy while this synchronous callback has control.
let data = buffer.to_vec();
std::thread::spawn(move || {
println!("data: {:?}", data);
});
}
WARNING
Send and Sync make it possible to move the wrapper; they do not
synchronize access to the bytes. JavaScript can retain and mutate the same
backing store while Rust holds it. Reading or writing that memory on a Rust
worker while JavaScript or another Rust thread can mutate it is a data race
and can cause undefined behavior. Copy the data before dispatching work, or
enforce an ownership protocol that rules out all unsynchronized access.
INFO
Cleanup is tied to the Rust wrapper's Drop, not to JavaScript GC. With the
napi4 feature, each Node-API environment/isolate has its own unreferenced
custom-GC ThreadsafeFunction. A wrapper dropped on its owning JavaScript
thread calls
napi_reference_unref
and
napi_delete_reference
directly. A wrapper dropped elsewhere sends its napi_ref to the
ThreadsafeFunction captured from the value's owning environment, whose
callback releases it on that environment's JavaScript thread.
If that environment has already shut down, NAPI-RS detects the aborted
handle and makes no further Node-API call because the runtime has already
invalidated the reference.
Releasing the Rust reference only makes the JavaScript value eligible for GC if JavaScript holds no other references. For Rust-created buffers, Rust owns the allocation until it is exported; then the JavaScript finalizer owns that allocation (or NAPI-RS copies it when the runtime rejects external buffers).
Borrowed Types - Function Scope Lifetime
Borrowed types (BufferSlice<'env>, Uint8ArraySlice<'env>, etc.) have lifetimes bound to the function scope:
- Zero-copy access to the underlying data
- Cannot cross async boundaries due to lifetime constraints
- Must be used within the same function call where they were created
use napi::bindgen_prelude::*;
use napi_derive::napi;
#[napi]
pub fn process_buffer_slice<'env>(env: &'env Env, data: &'env [u8]) -> Result<BufferSlice<'env>> {
// BufferSlice lifetime is bound to this function scope
BufferSlice::from_data(env, data.to_vec())
}
Buffer Lifetime Flowchart
flowchart TD
A[JavaScript calls Rust with a Buffer]
B{Rust parameter type}
C[BufferSlice<'env> or another scoped view]
D[Use only while the native call scope is open]
E[Native call returns]
F[Scoped Rust handle expires; JavaScript lifetime is independent]
G[Buffer or an owned typed array]
H[napi_create_reference in FromNapiValue]
I[Owned wrapper may cross await or thread boundaries]
J[Rust wrapper Drop]
K{Dropped on its owning environment thread?}
L[Unref and delete the napi_ref directly]
M[Queue the napi_ref on that environment's custom-GC TSFN]
N[Owning JavaScript thread unrefs and deletes it]
O[Rust's reference is released]
P{Does JavaScript retain another reference?}
Q[JavaScript value remains alive]
R[Value is eligible for JavaScript GC]
A --> B
B -->|Borrowed| C
C --> D
D --> E
E --> F
B -->|Owned| G
G --> H
H --> I
I --> J
J --> K
K -->|Yes| L
K -->|No| M
M --> N
L --> O
N --> O
O --> P
P -->|Yes| Q
P -->|No| R
When Lifetimes Matter
Function-scoped lifetime (BufferSlice<'env>):
use napi::bindgen_prelude::*;
use napi_derive::napi;
#[napi]
pub fn sync_only(env: &Env) -> Result<BufferSlice<'_>> {
// ✅ Works: BufferSlice lifetime tied to function scope
BufferSlice::from_data(env, vec![1, 2, 3])
}
// ❌ Won't compile: Cannot cross async boundaries
// #[napi]
// async fn async_fail(env: &Env) -> Result<BufferSlice<'_>> {
// let slice = BufferSlice::from_data(env, vec![1, 2, 3])?;
// napi::tokio::time::sleep(std::time::Duration::from_millis(100)).await;
// Ok(slice) // Error: slice doesn't live long enough
// }
The sleep examples require the async and tokio_time features on the napi
dependency.
Reference-backed lifetime (Buffer):
use napi::bindgen_prelude::*;
use napi_derive::napi;
#[napi]
pub async fn async_works(buffer: Buffer) -> Result<Buffer> {
// ✅ Works: Buffer is Send + Sync
napi::tokio::time::sleep(std::time::Duration::from_millis(100)).await;
Ok(buffer)
}
For more details on Buffer and TypedArray usage patterns, see the TypedArray documentation.
JavaScript Value Reference
For other values, reference wrappers such as ObjectRef, UnknownRef,
SymbolRef, FunctionRef, and ExternalRef use a napi_ref to keep a
JavaScript value alive beyond the current callback. The wrapper itself has no
scope lifetime, but that does not make JavaScript APIs environment-independent
or safe to call from arbitrary threads. Borrow the scoped value back with the
owning Env, and follow the type's release contract: some wrappers release on
Drop, while ObjectRef, UnknownRef, and SymbolRef require an explicit
unref(env) (or must be returned to JavaScript).
See Reference for more details.