Skip to content

Creating pools

createPool(options)(tasks) starts the worker threads and hands back three things:

  • 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 a using declaration close the pool when its scope ends.

Reach for using pool = createPool(...)({ ... }) and let the pool close itself. 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.

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);
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,
stallFreeLoops?: number,
maxBackoffMs?: number,
dispatcher?: "per-thread" | "serial-channel",
},
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,
})

Deprecated payload aliases are still accepted at the top level:

  • payloadInitialBytes -> payload.payloadInitialBytes
  • payloadMaxBytes -> payload.payloadMaxByteLength
  • bufferMode -> payload.mode
  • maxPayloadBytes -> payload.maxPayloadBytes

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.

These options tune the shared buffers that carry arguments out and results back.

Picks how the shared buffer is allocated:

  • "growable": starts at payloadInitialBytes and grows on demand, up to payloadMaxByteLength.
  • "fixed": allocates payloadMaxByteLength at startup and stays that size.

Growable is the default wherever growable SharedArrayBuffers exist, fixed everywhere else. Asking for "growable" on a runtime without them gets you "fixed" regardless — the request is downgraded, not refused.

How large a single payload buffer may grow, in bytes. Default 64 MiB.

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.

A hard ceiling on any one dynamically encoded payload. Must be > 0 and <= payloadMaxByteLength >> 3, which is also the default (8 MiB with everything else left alone).

Go over it and the call is rejected with KNT_ERROR_3, before a slot is even 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.

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.

How many abort-aware calls the pool can track at once, default 258. It only matters if a task declares abortSignal in the first place.

Only tasks defined with abortSignal: true or abortSignal: { hasAborted: true } count against the limit.

const pool = createPool({
threads: 4,
abortSignalCapacity: 1024,
})({ myAbortableTask });
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]
}

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 of roundRobin.
  • 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.

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 (default 1 when enabled).
  • dispatchThreshold: minimum in-flight calls per invoker before inline lane is eligible (default 1).

See Inliner guide for detail.

"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 the full story — sandboxes, containers, and 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.

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.

Set this to true and workers wait for every pending promise to settle before they exit.

What a worker does while it has nothing to run:

  • spinMicroseconds: busy-spin budget before parking.
  • parkMs: Atomics.wait timeout while parked.
  • pauseNanoseconds: Atomics.pause duration while spinning. Set 0 to disable.

A wall-clock timeout on every task call. Tripping it shuts the whole pool down, which is the only way to stop a worker already burning CPU in a tight loop.

Memory and stack limits for Node.js workers:

  • maxOldGenerationSizeMb
  • maxYoungGenerationSizeMb
  • codeRangeSizeMb
  • stackSizeMb

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.

Which permission flags the workers start with.

  • Leave permission out: strict defaults, plus allowImport: true so 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, console is false under strict and true under unsafe.

See Permissions guide for runtime-specific mapping and strict defaults.

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.

  • 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.kill and process.abort are blocked, along with Deno.exit where 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.

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.

Requests an asynchronous host completion waiter instead of repeated response mailbox polling. It is enabled by default when Node.js or Bun thread workers provide Atomics.waitAsync. It is disabled for Deno, process workers, compiled workers, and browsers, where polling is used instead. Setting it to true cannot override a runtime limitation; set it to false for a polling baseline.

How many immediate dispatcher turns run before escalation. The default is 1 when the doorbell is active and 128 when the dispatcher must poll.

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.

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 relative to when that worker’s debug initialised. 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: globalThis changes 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 env var merge; either one can turn a namespace on. Debug is zero-cost when off: with no namespace active, the logger module is never even imported.

Point workers at a specific entry module instead of the one Knitting resolves for you.

One pool holds up to 65,536 tasks — function IDs are Uint16, so the range is 0..0xFFFF. Hand it more than that and you get a RangeError.