Workers

Contents

env.runWorker offloads CPU work to a background thread and returns a JS Promise. It is the right tool for single-result async work: parsing, hashing, image processing, anything that takes long enough to block the main thread.

For multi-call async patterns (progress events, streaming), use a ThreadsafeFn instead.

Pointer-free worker contexts are copied by value. A context containing any pointer or slice, including a nested one, must implement the ownership hooks described below; runWorker invokes them before queueing and releases the owned context exactly once on every failure, cancellation, and completion path. Env, Val, Callback, CallInfo, and containers that include them are rejected at compile time even if hooks are present: these handles are scoped to the originating JavaScript call and must never be read on the worker thread or retained for resolve. A persistent Ref is a separate, explicitly managed API and may only be used with an Env back on the main thread.

The pattern

Define a struct with two methods:

  • compute(*Self) void runs on the worker thread. No JS access here. Treat it like any other Zig function.
  • resolve(*Self, Env) !T runs on the main thread. The return value (or error) becomes the promise result. T may be any convertible Zig type, napi.Val, or void.
const FibWork = struct {
    n: i32,
    result: i32 = 0,

    pub fn compute(self: *FibWork) void {
        self.result = fib(self.n);
    }

    pub fn resolve(self: *FibWork, _: napi.Env) !i32 {
        return self.result;
    }

    fn fib(n: i32) i32 {
        if (n <= 1) return n;
        return fib(n - 1) + fib(n - 2);
    }
};

pub fn asyncFib(env: napi.Env, n: i32) !napi.Val {
    return env.runWorker("fib", FibWork{ .n = n });
}
const result = await asyncFib(10); // 55

The first argument to runWorker is a name shown in the Node async hooks API. The second is the context struct passed to compute and resolve. Pointer-free context is copied directly; pointer-backed context is first cloned through its ownership hooks.

Errors

If resolve returns an error, the Promise rejects with a real JS Error whose .message is the Zig error name:

pub fn resolve(self: *FibWork, _: napi.Env) !i32 {
    if (self.n < 0) return error.InvalidInput;
    return self.result;
}
asyncFib(-1).catch((e) => console.log(e.message)); // "InvalidInput"

Return-value conversion failures also reject the Promise. The bridge settles each worker Promise once; if Node-API itself reaches an unrecoverable state where it cannot resolve or reject the deferred, the process fails explicitly rather than silently leaving a Promise pending forever.

Setup that can fail before a Promise exists—context cloning, allocation, async resource naming, and async-work creation—returns a normal Zig error, which an exported function exposes as one synchronous JavaScript exception. The bridge never creates and rejects a hidden Promise on those paths. Once Promise creation succeeds, a queue failure returns that Promise already rejected instead of also throwing, so callers still observe exactly one failure channel.

compute itself does not return a value, so to surface a "computation failed" outcome, store state on self and check it from resolve:

const ParseWork = struct {
    source: []const u8,
    failed: bool = false,
    item_count: usize = 0,

    pub fn cloneForWorker(self: @This(), allocator: std.mem.Allocator) !@This() {
        var cloned = self;
        cloned.source = try allocator.dupe(u8, self.source);
        return cloned;
    }

    pub fn deinitWorker(self: *@This(), allocator: std.mem.Allocator) void {
        allocator.free(self.source);
    }

    pub fn compute(self: *ParseWork) void {
        self.item_count = countItems(self.source) catch {
            self.failed = true;
            return;
        };
    }

    pub fn resolve(self: *ParseWork, _: napi.Env) !usize {
        if (self.failed) return error.ParseFailed;
        return self.item_count;
    }
};
Danger

A panic in compute (index out of bounds, unreachable, integer overflow) crashes the entire Node.js process. There is no way to recover from a panic on a worker thread. Use error returns for anything that can fail.

Memory across the thread boundary

The per-call arena is destroyed when the exported Zig function returns, before asynchronous work can safely use decoded strings, slices, or nested pointer-backed fields. To make that ownership explicit, a pointer-backed context must declare both hooks with these exact signatures:

pub fn cloneForWorker(self: @This(), allocator: std.mem.Allocator) !@This()
pub fn deinitWorker(self: *@This(), allocator: std.mem.Allocator) void

cloneForWorker must return a context whose entire pointer graph remains valid independently of the originating call. deinitWorker releases that graph; the bridge calls it exactly once after cloneForWorker succeeds, including create/queue failure, cancellation, completion error, and normal completion. A pointer that refers to immutable static storage may be copied shallowly only when the matching no-op cleanup is intentional and safe.

A typical slice-backed context is:

const ParseWork = struct {
    source: []const u8,
    result: u32 = 0,

    pub fn cloneForWorker(self: @This(), allocator: std.mem.Allocator) !@This() {
        var cloned = self;
        cloned.source = try allocator.dupe(u8, self.source);
        return cloned;
    }

    pub fn deinitWorker(self: *@This(), allocator: std.mem.Allocator) void {
        allocator.free(self.source);
    }

    pub fn compute(self: *@This()) void {
        self.result = parse(self.source);
    }

    pub fn resolve(self: *@This(), _: napi.Env) !u32 {
        return self.result;
    }
};

pub fn asyncParse(env: napi.Env, source: []const u8) !napi.Val {
    return env.runWorker("parse", ParseWork{ .source = source });
}

Do not also free cloned fields from compute or resolve; deinitWorker owns cleanup. If cloning only some fields succeeds, cloneForWorker itself must clean up that partial work before returning an error.

Returning JS values directly

If you want resolve to return a hand-built napi.Val instead of a typed Zig value (for Buffers, dynamic-key objects, anything outside the conversion table):

pub fn resolve(self: *Work, env: napi.Env) !napi.Val {
    const obj = try env.createObject();
    try obj.setNamedProperty(env, "data", try env.toJs(self.bytes));
    return obj;
}

Val is a recognized return type. The bridge passes it through unconverted.

When not to use a worker

  • Synchronous work that completes in microseconds. The thread hop has its own cost. Just compute on the main thread.
  • Multi-call patterns. Progress events, streaming, anything where the worker pushes more than one value. Use ThreadsafeFn.
  • I/O-bound work. If the work spends its time waiting for the kernel, use Node's existing async I/O instead of pinning a thread.