Skip to content

Creating pools

createPool(options)(tasks) starts worker threads and returns helpers:

  • call.<task>(args) enqueue a task and return a promise.
  • shutdown(delayMs?) stop workers immediately or after an optional delay.
  • [Symbol.dispose] so a using declaration closes the pool when its scope ends.

Prefer using pool = createPool(...)({ ... }) and let the pool dispose itself. shutdown() is still there, though — call await pool.shutdown() to close the pool before the using scope ends, when you need to await teardown, or when you are on a runtime without using. using simply calls shutdown() for you at scope exit.

call.*() always returns a promise. Inputs may also be native promises; they are resolved on the host before dispatch. If an input promise rejects, the host call rejects and the worker task is not executed.

See Promise inputs and awaited outputs.

call.*() enqueues work and dispatches automatically. For batches, create all calls first and then await them together.

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",
processRuntime?: "node" | "deno" | "bun",
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?: {
stallFreeLoops?: number,
maxBackoffMs?: number,
},
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

Number of worker threads to spawn (default 1). The total lane count is threads + (inliner ? 1 : 0).

Payload transport tuning lives under payload.

Selects shared-buffer transport mode:

  • "growable": growable shared array buffer mode.
  • "fixed": fixed-size shared array buffer mode.

Default is "growable" when SAB growth is available, otherwise "fixed".

Maximum size (in bytes) each payload buffer may grow to. Default is 64 MiB.

Initial payload buffer size in bytes. Default is 4 MiB in growable mode. In fixed mode, startup uses the full payloadMaxByteLength.

Hard cap for dynamic payload encoding. Must be > 0 and <= payloadMaxByteLength >> 3. Default is payloadMaxByteLength >> 3 (8 MiB with defaults).

Calls that exceed this cap are rejected with KNT_ERROR_3 before dynamic slot reservation.

If payload.mode is "fixed" and payload size is under the cap but still does not fit current capacity, the call is rejected with a controlled encoder error.

Payload limits apply per worker and per direction (request payload + return payload), so each worker allocates two payload buffers with these limits.

Maximum number of concurrent abort-aware calls the pool can track. Default is 258, and it applies only when at least one task declares abortSignal.

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

const pool = createPool({
threads: 4,
abortSignalCapacity: 1024,
})({ myAbortableTask });

Basic 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]
}

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.

When only one thread is spawned and no inliner is enabled, the balancer is bypassed and calls go directly to the single 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.

A privileged module imported and awaited once per worker before task modules load: { href, name?, data? }. Use it to install runtime guards, strip environment variables, or prepare worker-only globals. Bootstrap is worker-only and cannot be combined with the inline lane.

When set to true, workers wait for all pending promises to settle before exiting.

Idle behavior tuning while workers have no work:

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

Hard wall-clock timeout for each task call. On timeout, the pool force-shuts down to stop runaway CPU execution.

Node.js worker memory/stack limits:

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

Runtime permission flag policy for workers.

  • Omit permission: strict defaults plus allowImport: true (web imports allowed).
  • "strict" (default when passing an object): computes conservative defaults.
  • "unsafe": disables permission flags and strips inherited Node permission flags.
  • In object mode, console defaults to false in strict mode and true in unsafe mode.

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

Worker timing paths capture a high-resolution performance.now() reference at module load/startup time. This keeps scheduling and timeout precision stable without freezing global performance.

  • Startup-only guard layer: safety hooks are installed once before the worker loop starts (no extra checks inside the hot task-processing loop).
  • Process termination APIs from task code are blocked: process.exit, process.kill, process.abort, plus Deno.exit when present.
  • Permission enforcement is delegated to runtime-native mechanisms (Node worker permission flags and Deno worker permissions when available). In-process FS/network/env monkey-patching is not performed.

Host dispatcher backoff and scheduling options.

How many notify loops run before backoff starts (default 128).

Maximum backoff delay in milliseconds once the dispatcher starts stalling (default 10).

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.

Override worker entry module URL/path explicitly.

A single pool supports up to 65,536 tasks (function IDs are stored as Uint16, range 0..0xFFFF). Passing more tasks throws a RangeError.