Creating pools
createPool(options)(tasks) starts the workers and returns a pool with:
call.<task>(args)enqueues a task and returns a promise.shutdown(delayMs?)stops the workers, either now or after a delay.[Symbol.dispose], which is what lets ausingdeclaration close the pool when its scope ends.
Use using pool = createPool(...)({ ... }) to close the pool automatically.
await pool.shutdown() is for the cases using cannot cover: closing before
the scope ends, awaiting teardown, or running where using does not exist.
Arguments may be promises as well as plain values. Knitting resolves them on the host before dispatch, so a rejected input rejects the call and the worker never runs it.
See Promise inputs are awaited on the host.
Batching pattern
Section titled “Batching pattern”Enqueued calls dispatch on their own, so the way to get a batch moving is to create every call first and await them together afterwards.
const jobs = Array.from({ length: 1_000 }, () => call.hello());const results = await Promise.all(jobs);Options
Section titled “Options”createPool({ threads?: number, inliner?: { position?: "first" | "last", batchSize?: number, dispatchThreshold?: number, }, balancer?: { strategy?: | "roundRobin" | "robinRound" | "firstIdle" | "randomLane" | "firstIdleOrRandom" } | "roundRobin" | "robinRound" | "firstIdle" | "randomLane" | "firstIdleOrRandom", worker?: { runtime?: "thread" | "process" | "compiled", processRuntime?: "node" | "deno" | "bun" | "porffor", processCommandPrefix?: string[], processSharedMemory?: "inherit" | "named" | { mode?: "inherit" | "named", namePrefix?: string, unlinkOnShutdown?: boolean, }, bootstrap?: { href: string, name?: string, data?: unknown }, resolveAfterFinishingAll?: true, timers?: { spinMicroseconds?: number, parkMs?: number, pauseNanoseconds?: number, }, hardTimeoutMs?: number, resourceLimits?: { maxOldGenerationSizeMb?: number, maxYoungGenerationSizeMb?: number, codeRangeSizeMb?: number, stackSizeMb?: number, }, }, payload?: { mode?: "growable" | "fixed", payloadInitialBytes?: number, payloadMaxByteLength?: number, maxPayloadBytes?: number, }, abortSignalCapacity?: number, host?: { steal?: boolean, stealRegionLanes?: number, doorbell?: boolean, nativeDoorbell?: boolean, stealClaim?: "dekker" | "cas-mask", stallFreeLoops?: number, maxBackoffMs?: number, dispatcher?: "per-thread" | "serial-channel", }, unsafe?: { SharedBytes?: boolean, SharedArgs?: boolean, }, workerExecArgv?: string[], permission?: "strict" | "unsafe" | PermissionProtocol, dispatcher?: DispatcherSettings, // deprecated alias of host debug?: boolean | { host?: boolean, globals?: boolean, signals?: boolean, imports?: boolean, lifecycle?: boolean, }, source?: string,})When unsafe.SharedArgs is enabled, the returned pool also exposes
sharedArgBytes(byteLength). It allocates a borrowed argument view in the
shared submit arena when the topology supports one, and otherwise returns a
private Uint8Array:
using pool = createPool({ threads: 4, unsafe: { SharedArgs: true },})({ task });
const input = pool.sharedArgBytes(256 * 1024);input.set(source);await pool.call.task(input);Borrowed argument bytes must be consumed before the task’s first suspension
point, and the host must keep only a few calls in flight at a time — the submit
arena is recycled, so over-allocating corrupts in-flight arguments silently. See
Shared memory before
using this option. For borrowed worker returns, enable unsafe.SharedBytes and use
sharedBytes() from knitting/unsafe; see Buffer reference
for its lifetime rules. Both unsafe options are disabled by default.
Deprecated payload aliases are still accepted at the top level:
payloadInitialBytes->payload.payloadInitialBytespayloadMaxBytes->payload.payloadMaxByteLengthbufferMode->payload.modemaxPayloadBytes->payload.maxPayloadBytes
threads
Section titled “threads”How many worker threads to spawn (default 1). Lanes are counted as
threads + (inliner ? 1 : 0).
See Multi-threading for choosing a worker count and configuring idle-worker timers in a server.
payload
Section titled “payload”These options tune the shared buffers that carry arguments out and results back.
Picks how the shared buffer is allocated:
"growable": starts atpayloadInitialBytesand grows on demand, up topayloadMaxByteLength."fixed": allocatespayloadMaxByteLengthat startup and stays that size.
The default is "growable" when the runtime supports growable
SharedArrayBuffers, and "fixed" otherwise. If you request "growable" on an
unsupported runtime, Knitting falls back to "fixed".
payloadMaxByteLength
Section titled “payloadMaxByteLength”How large a single payload buffer may grow, in bytes. Default 64 MiB.
payloadInitialBytes
Section titled “payloadInitialBytes”The size a buffer starts at, in bytes, default 4 MiB. Growable mode clamps it
to payloadMaxByteLength; fixed mode ignores it and allocates the full
payloadMaxByteLength up front.
maxPayloadBytes
Section titled “maxPayloadBytes”A hard ceiling on any one dynamically encoded payload. Must be > 0 and
<= payloadMaxByteLength >> 3, which is also the default (8 MiB with
the default settings).
Calls that exceed this limit are rejected with KNT_ERROR_3 before a slot is
reserved.
In "fixed" mode a payload can sit under the cap and still not fit what is left
of the buffer. There is no room to grow into, so that call is rejected with an
encoder error.
How the limits are applied
Section titled “How the limits are applied”Every limit above is per worker and per direction. Each worker allocates two buffers, one for arguments and one for results, and each buffer gets the full allowance.
abortSignalCapacity
Section titled “abortSignalCapacity”How many abort-aware calls the pool can track at once, default 258. It applies only
when at least one task declares abortSignal.
Only tasks defined with abortSignal: true or
abortSignal: { hasAborted: true } count against the limit.
const pool = createPool({ threads: 4, abortSignalCapacity: 1024,})({ myAbortableTask });Example
Section titled “Example”import { createPool, isMain, task } from "knitting";
export const add = task<[number, number], number>({ f: async ([a, b]) => a + b,});
if (isMain) { using pool = createPool({ threads: 2 })({ add });
const results = await Promise.all([ pool.call.add([1, 2]), pool.call.add([3, 4]), ]); console.log(results); // [3, 7]}balancer
Section titled “balancer”Controls how calls are routed across lanes (threads, plus optional inliner).
Pass a string or an object with a strategy key.
roundRobin(default): round-robin rotation through all lanes.robinRound: legacy alias ofroundRobin.firstIdle: pick the first idle lane, else fall back to round-robin.randomLane: pick a random lane.firstIdleOrRandom: pick the first idle lane, else random.
With one thread and no inliner there is nothing to balance, so calls skip the balancer and go straight to that worker.
inliner
Section titled “inliner”Adds an extra lane that runs tasks on the main thread.
position: whether the inline lane appears before ("first") or after ("last") the worker lanes for balancing.batchSize: max tasks processed per event-loop tick (default1when enabled).dispatchThreshold: minimum in-flight calls per invoker before inline lane is eligible (default1).
See Inliner guide for detail.
worker
Section titled “worker”runtime
Section titled “runtime”"thread" (default) runs workers as runtime-local threads — the lowest-overhead
option. "process" runs each worker as a separate OS process for stronger
isolation, and unlocks processRuntime, processCommandPrefix, and
processSharedMemory. See Process workers for
sandbox and container configuration, including the stdin / fd-0 handshake.
"compiled" builds the task module into a native executable with Porffor and
runs that as a child process. It is experimental, and supports a smaller feature
set than the other two — see Compiled workers.
bootstrap
Section titled “bootstrap”A privileged module — { href, name?, data? } — that every worker imports and
awaits once, before any task module loads. Use it to install runtime guards,
strip environment variables, or set up worker-only globals. It is worker-only,
so it cannot be combined with the inline lane.
resolveAfterFinishingAll
Section titled “resolveAfterFinishingAll”Set this to true and workers wait for every pending promise to settle before
they exit.
timers
Section titled “timers”What a worker does while it has nothing to run:
spinMicroseconds: busy-spin budget before parking.parkMs:Atomics.waittimeout while parked.pauseNanoseconds:Atomics.pauseduration while spinning. Set0to disable.
hardTimeoutMs
Section titled “hardTimeoutMs”A wall-clock timeout on every task call. When it expires, Knitting shuts down the whole pool, stopping even a worker stuck in a tight CPU loop.
resourceLimits
Section titled “resourceLimits”Memory and stack limits for Node.js workers:
maxOldGenerationSizeMbmaxYoungGenerationSizeMbcodeRangeSizeMbstackSizeMb
workerExecArgv
Section titled “workerExecArgv”Extra Node.js execArgv flags passed to workers, for example
["--expose-gc", "--max-old-space-size=4096"].
When permission is set to "unsafe", inherited Node permission flags
(--allow-fs-read, --allow-fs-write, etc.) are stripped.
permission
Section titled “permission”Which permission flags the workers start with.
- Leave
permissionout: strict defaults, plusallowImport: trueso web imports still work. "strict"(what you get when you pass an object): conservative defaults, worked out per runtime."unsafe": no permission flags at all, and any inherited Node ones are stripped.- In object mode,
consoleisfalseunder strict andtrueunder unsafe.
See Permissions guide for runtime-specific mapping and strict defaults.
Timing note
Section titled “Timing note”Each worker takes one high-resolution performance.now() reading at startup and
measures everything against it. Scheduling and timeouts stay precise that way,
and global performance is left alone for your own code to use.
Safety hardening defaults
Section titled “Safety hardening defaults”- The guards go in once, before the worker loop starts, so nothing extra runs inside the hot task loop.
- Task code cannot take the process down:
process.exit,process.killandprocess.abortare blocked, along withDeno.exitwhere it exists. - Permissions are enforced by the runtime itself — Node’s worker permission flags, Deno’s worker permissions — rather than by monkey-patching FS, network or env from inside the worker.
Controls host-side scheduling and completion handling. The defaults select native work stealing for compatible multi-worker pools and use the best completion waiter available on the runtime. These options affect the host dispatcher, not task arguments or worker code.
See Work stealing for the topology, runtime support, and tuning guidance.
using pool = createPool({ threads: 4, host: { steal: true, doorbell: true, },})({ task });When enabled, workers claim tasks from one shared submit region instead of waiting behind private request lanes. Each worker keeps a private return lane, so the worker that claims a task also owns its response. The pool’s pending registry still resolves the correct promise.
For compatible pools with more than one worker, native work stealing is selected
automatically. A one-worker pool has nothing to steal from. An explicit
balancer, private-lane dispatcher, inliner, compiled worker, or an unsupported
worker count can change that compatibility decision.
Set host.steal: false to measure or use private request lanes. The task API,
payload types, and completion semantics stay the same in either topology.
stealRegionLanes
Section titled “stealRegionLanes”Controls how many submit slots one stealing handshake claims. It must be a
positive power of two; the default is the widest valid region for the worker
count. Wider regions reduce arbitration overhead for many cheap, similarly
sized calls. Smaller regions expose more independent work for expensive or
uneven tasks. Start with 1 when task durations vary substantially, then
benchmark the real workload.
stealClaim
Section titled “stealClaim”Selects the region-claim discipline for work stealing. The default is
"dekker"; "cas-mask" uses a shared compare-and-swap mask. The same choice
can be set with KNITTING_STEAL_CLAIM, but the explicit host option wins.
Dekker requires at least one spare region per live consumer, so it can impose a
lower ceiling on an explicit stealRegionLanes than "cas-mask". See
Work stealing for the topology and limits.
doorbell
Section titled “doorbell”Requests an asynchronous host completion waiter instead of repeated response
mailbox polling. It is enabled by default when the runtime provides a wake path:
Atomics.waitAsync on Node/Bun threads, a thread-safe FFI callback on Deno when
FFI is available, and a process-local completion transport for process workers.
Unsupported or denied configurations fall back to polling. Set it to false
for a polling baseline.
nativeDoorbell
Section titled “nativeDoorbell”On Node thread workers, requests the optional native uv_async_t completion
bridge from the knitting_doorbell addon. It is false by default, requires
doorbell: true, does not apply to process workers, and falls back when the
prebuild or permission is unavailable.
stallFreeLoops
Section titled “stallFreeLoops”How many immediate dispatcher turns run before escalation. The default is 1
when the doorbell is active and 128 when the dispatcher must poll.
maxBackoffMs
Section titled “maxBackoffMs”The longest the dispatcher will wait between polls once it starts stalling, in
milliseconds (default 10).
maxBackoffMs affects only the polling fallback; it does not change the
doorbell’s asynchronous wait.
dispatcher
Section titled “dispatcher”Deprecated alias of host.
Streams diagnostics to stderr, each line tagged with the worker (host, or
w0, w1, … for the thread/process workers), the runtime, and a millisecond
timer measured from when diagnostics were initialized for that worker. Pass true to enable
everything, or turn on individual namespaces:
host: host-side pool setup — cwd and caller, each registered task, runtime / workers / lanes / inliner, the module list, permission mode, and worker bootstrap.imports: how many tasks each worker loaded, and from which modules.lifecycle: the worker “ready” line and process-worker lifecycle events.signals: per-dispatch worker traffic (work / result / run / idle). Very chatty.globals:globalThischanges across the worker’s bootstrap and task phases, so you can see which loader injected which global.
Enable the same namespaces without touching code through the KNITTING_DEBUG
environment variable — a comma-separated list (KNITTING_DEBUG=host,imports) or
* for all. The option and the environment variable are merged; either can enable a
namespace. When no namespace is active, the logger module is not imported.
source
Section titled “source”Point workers at a specific entry module instead of the one Knitting resolves for you.
Limits
Section titled “Limits”One pool holds up to 65,536 tasks — function IDs are Uint16, so the range
is 0..0xFFFF. Registering more than that throws a RangeError.