Skip to content

Quick Start

Define your tasks, create a pool, and call them like async functions. The pool closes automatically when you are done. This guide walks through each step.

Knitting gives workers a function-call API with low communication overhead. If you are new to workers, start with these terms.

  • Task: a function the workers can run. An exported function already counts as one; wrap it in task({ f }) when you want options like timeouts or aborts.
  • call.*(): runs a task on the pool and returns a Promise with the result.
  • isMain: true on the host, false inside a worker. Workers re-import your module, so anything that should happen once — creating the pool, starting a server — belongs behind this check.
  • createPool(): starts the workers and returns a pool with a typed call object and a shutdown() method. The pool is disposable, so using can close it.
  • Host ↔ Worker: the host is the process that creates the pool. The workers are the threads (or processes) that run the tasks.

These four examples cover a single task, parallel calls, multiple tasks, and task options.

hello_world.ts
import { createPool, isMain } from "knitting";
// A task is just an exported function the workers can run.
export const greet = (name: string) => `hello ${name}`;
if (isMain) {
// `using` shuts the pool down automatically when this block ends.
using pool = createPool({ threads: 1 })({ greet });
console.log(await pool.call.greet("knitting")); // hello knitting
}
  1. Import what you need:

    import { createPool, isMain } from "knitting";
  2. Export your tasks at module scope. Workers find them by name, so they have to be reachable from the top level of the file:

    export const square = (n: number) => n * n;
    export const greet = (name: string) => `hello ${name}`;
  3. Create the pool behind isMain. Workers re-import this module, and without the guard every one of them would try to start a pool of its own:

    if (isMain) {
    using pool = createPool({ threads: 2 })({ square, greet });
    }

    using closes the pool when the block ends, so there is nothing to clean up.

  4. Call the tasks. They hand back ordinary promises, so Promise.all batches them:

    if (isMain) {
    using pool = createPool({ threads: 2 })({ square, greet });
    const [n, message] = await Promise.all([
    pool.call.square(8),
    pool.call.greet("knitting"),
    ]);
    console.log({ n, message }); // { n: 64, message: "hello knitting" }
    }
  5. Shut down when you are done.

    With using, the workers stop when the block ends and the process can exit. Call shutdown() yourself to close the pool earlier, or if your runtime does not support using:

    const pool = createPool({ threads: 2 })({ square, greet });
    try {
    console.log(await pool.call.square(8));
    } finally {
    await pool.shutdown();
    }

For a short script with one task, chain .createPool() onto the task definition:

import { isMain, task } from "knitting";
export const double = task({
f: (n: number) => n * 2,
}).createPool({ threads: 2 });
if (isMain) {
try {
console.log(await double.call(21)); // 42
} finally {
await double.shutdown();
}
}

These habits help keep worker startup fast and make common problems easier to avoid.

Workers load the task module and its imports. Keeping that module small helps them start quickly without loading unrelated application code.

  • package.json
  • deno.json
  • Directorysrc
    • Directoryknitting
      • database.ts
      • img_parsing.ts
      • jwt.ts
    • Directoryapp/
      • …
    • Directorypages/
      • …

call.*() accepts Promise<supported> inputs. Knitting resolves them on the host before dispatch, so unresolved promise state never crosses the thread boundary. Request handlers get this for free — hand the call a body you have not read yet:

app.post("/validate", async (c) => {
const result = await pool.call.validate(c.req.text());
return c.json(result);
});

If that promise rejects, the call rejects on the host and the worker never runs.

Small, flat payloads are cheaper to transfer. Prefer numbers, booleans, and short strings when they fit your data, then typed arrays, Buffer, or compact JSON. When you have bytes plus some metadata to describe them, use Envelope. See Supported payloads.

Worker permissions start restricted. Sensitive paths like .env, .git, ~/.ssh and /etc are blocked, and writes to node_modules are denied. Grant only the access your tasks need. See Permissions.

Almost everything here follows from one fact: workers re-import the module that defines your tasks.

  • One argument per task. pool.call.add(a, b) won’t work — pass a tuple or object: pool.call.add([a, b]) for ([a, b]) => a + b.
  • Guard host code with isMain. Without it, pool creation (and any other host-only code) re-runs inside every worker.
  • Top-level imports run in every worker. import is hoisted, so it executes before any isMain check. Keep tasks in their own lean module so workers don’t load your whole server framework.
  • Export your tasks. An unexported task() / importTask() is invisible to the worker loader, so the call just hangs — no handler is ever registered.
  • importTask targets are plain functions, not task() wrappers (that throws a TypeError). Put timeout / abortSignal options on the importTask call instead.
  • Worker console.* is silent by default in strict mode. Pass permission: { console: true } to surface worker logs.
  • Can’t tell what the pool is doing? Pass debug: true to createPool, or set KNITTING_DEBUG=*. Setup, import and lifecycle diagnostics go to stderr, each line tagged with its worker and a millisecond timer. If that is too much, name the parts you care about: debug: { host: true, imports: true }. When diagnostics are disabled, the logger module is not imported.
  • Only supported payloads cross the boundary. Map, Set, class instances, and functions are rejected — see Payloads.
  • Dynamic payloads cap at ~8 MiB by default; raise payload.maxPayloadBytes (and payload.payloadMaxByteLength) for larger ones.

For stronger isolation, workers can each run as a separate process, inside a bwrap sandbox or a container, when threads do not provide enough isolation. Large buffers can skip the copy entirely with ProcessSharedBuffer, which puts the bytes in shared memory instead of sending them.