Skip to content

Workflow: Async Work Pattern

Fresh 🌱

The canonical pattern for doing real work without blocking the event loop: take the request on the main thread, hand JavaScript a promise immediately, do the heavy work on another thread, then settle the promise back on the main thread.

The flow

sequenceDiagram
    participant JS as JavaScript
    participant MAIN as Main thread (Rust)
    participant CH as Channel
    participant WORK as Worker / spawned thread

    JS->>MAIN: call asyncFn(args)
    MAIN->>MAIN: cx.promise() -> (deferred, promise)
    MAIN-->>JS: return promise (pending)
    MAIN->>WORK: spawn work (move Roots + deferred + channel)
    Note over WORK: Pure Rust work,
no JS access here WORK->>CH: deferred.settle_with(&channel, closure) CH->>MAIN: schedule closure on main thread MAIN->>MAIN: build JS result value MAIN-->>JS: promise resolves

The shape in code

rust
fn async_operation(mut cx: FunctionContext) -> JsResult<JsPromise> {
    let input: String = cx.argument::<JsString>(0)?.value(&mut cx);
    let channel = cx.channel();              // bridge back to main thread
    let (deferred, promise) = cx.promise();  // give JS a promise now

    std::thread::spawn(move || {
        let result = input.to_uppercase();   // heavy work, off the event loop

        deferred.settle_with(&channel, move |mut cx| {
            Ok(cx.string(result))            // build JS value on main thread
        });
    });

    Ok(promise)                              // returned immediately, still pending
}

The three rules this pattern enforces

  1. Return fast. The function returns the pending promise right away, so the event loop is never blocked.
  2. No JS off the main thread. The spawned thread does only pure Rust work. Any JavaScript value creation happens inside the Channel closure, which runs on the main thread.
  3. Cross the boundary with the right carriers. Move a Deferred and (if you need callbacks/objects) a Root into the thread. Use the Channel to schedule the JS-touching closure home.

Two shortcuts

If your work is...Use the shortcut
CPU-bound, fits the worker poolcx.task(|| heavy()).promise(|cx, r| ...) or #[neon::export(task)]
Async Rust I/O (network, etc.)#[neon::export] async fn with the futures feature

Both wrap this exact pattern so you do not manage the channel and deferred by hand. Full details in Async Tasks, Promises & Channels.

The most common bug

Moving a Handle or FunctionContext into the spawned thread will not compile (and must not) - they are not Send. Move a Root and re-derive the handle inside the Channel closure with root.into_inner(&mut cx).