Async & nonblocking calls
Run slow native calls off the JS thread with async: true.
Mark a symbol async: true in the schema to run it on a thread pool. The bound function returns a Promise instead of a synchronous value.
import { , } from 'unffi'
const = await ('./codec.dylib', {
: { : [., .], : ., : true },
: { : [., ., ., .], : ., : true },
})
const result = ..(new (0), 0)TypeScript infers Promise<number> from async: true and returns: t.i32 — no annotations.
When to use it
Async calls add overhead (thread pool dispatch, Promise allocation). Use when:
- The native call blocks for >1ms (compression, hashing, image processing)
- You want to keep the event loop responsive during CPU-intensive work
- You're processing many items and want concurrent throughput via
Promise.all
For fast operations (integer math, simple lookups), synchronous calls are cheaper.
Concurrent throughput
const inputs: Uint8Array[] = /* ... */
const results = await Promise.all(
inputs.map((buf) => lib.symbols.compress(buf, buf.byteLength))
)The native calls run concurrently on the thread pool. JS awaits all of them.
Per-runtime behaviour
Uses bun:ffi nonblocking: true. Runs on Bun's libuv thread pool.
Uses Deno.dlopen nonblocking: true. Dispatches via Deno's async op system.
Uses koffi's async worker pool (fn.async(..., callback)). Wrapped in a Promise by unffi.
Zero-copy with async
t.buffer passes the backing pointer directly — no copy on input. Async calls work the same way:
const input = new Uint8Array(largeData)
const output = new Uint8Array(maxOutputSize)
// Neither buffer is copied — C reads input and writes output directly
const written = await lib.symbols.compress(input, input.byteLength)
// output[0..written] contains the compressed bytesDon't mutate or GC a t.buffer argument while an async call is in flight. The native thread holds a raw pointer to the backing memory.