---
title: "Worker threads and cluster"
description: "Run CPU-heavy work on a pool of worker threads or processes, and run one server instance per core, declared in main.yaml and carrying the tenant."
section: "interfaces"
status: "Building for 0.4 · ships as a beta API"
spec: "parallelism"
canonical: "https://mainsdk.dev/docs/interfaces/workers"
updated: "2026-10-28"
---

# Worker threads and cluster

> Run CPU-heavy work on a pool of worker threads or processes, and run one server instance per core, declared in main.yaml and carrying the tenant.

Status: Building for 0.4 · ships as a beta API · Canonical: https://mainsdk.dev/docs/interfaces/workers · Updated: 2026-10-28

`workers` runs CPU-heavy JavaScript on a pool of worker threads so requests keep flowing, and `cluster` runs one server instance per core. Both are declared in `main.yaml`, run in the app and are free.

> **Note:** `workers` is in 0.4 (spec `parallelism`, ADR-0068). Worker threads, process pools, the inline modes and `cluster` are built. Two built-in modules wait for the interfaces they serve: `mainsdk:imports.xlsx` and `mainsdk:seal.hash`. Node's `crypto.subtle`, `pbkdf2`, `scrypt`, `zlib` and file reads already run on the libuv thread pool, so MainSDK never wraps them in workers. To give them more room, raise `UV_THREADPOOL_SIZE`.

## At a glance

- Status: Building for 0.4 · ships as a beta API
- Phase: 1 (Core, public beta)
- Milestone: 0.4 Hardening
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/parallelism.md
- Category: Platform, OS and network

Calls:

- `workers.run(module, input, opts)`
- `workers.snapshot()`
- `worker(fn)`
- `cluster.run(main)`
- `cluster.restart(opts)`
- `cluster.snapshot()`
- `cluster.role()`
- `cluster(object: one instance per core)`
- `parseWorkers(section, ctx)`
- `validateWorkers(config, ctx)`

In main.yaml:

```yaml
workers:
  size: auto
  queue: 1000
  timeout: 30s
  memory: 256MB
  idle: 30s
  env: [TZ]
  modules: ["src/workers/*.worker.ts"]
  builtin: on
  pools:
    pdf: { size: 2, isolation: process, memory: 1GB, modules: [src/workers/pdf.worker.ts] }
cluster:
  processes: auto
  start-timeout: 30s
  respawn: { backoff: 1s, max: 30s, crashes: 5, window: 60s }
```

Guarantees, whatever the driver:

- Only declared worker modules run
- Pools are sized from the cores the process may use, cgroup quota included
- Every task has a timeout and a signal; a stuck thread is terminated and replaced
- The tenant, trace and audit trail cross into every task; secrets and the environment don't
- Cluster instances share nothing but the port, the local store and broadcasts, so limits stay correct
- Rolling restarts and shutdown go through the health drain
- Free on every plan


## Drivers

Pick one in main.yaml with `workers.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| Built in | `builtin` | yes |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-WORKER-0001` | check | `node:worker_threads`, `node:cluster` or `child_process.fork` used outside the SDK |
| `MAIN-WORKER-0009` | check | A `workers` or `cluster` value is invalid: a size, `processes` or `queue` out of range, a `memory` that isn't a size, a module glob that matches no file, a module in two pools, or an `env` name that is a secret's name |
| `MAIN-WORKER-0010` | check | Workers or cluster on a target that can't run them: cluster with more than one process on serverless, edge or Deno fails; workers on a runtime without threads warn that tasks run inline |
| `MAIN-WORKER-0016` | check | A worker module or `cluster.run` found in the code that `main.yaml` doesn't declare |
| `MAIN-WORKER-0002` | runtime |  |
| `MAIN-WORKER-0003` | runtime |  |
| `MAIN-WORKER-0004` | runtime |  |
| `MAIN-WORKER-0005` | runtime |  |
| `MAIN-WORKER-0006` | runtime |  |
| `MAIN-WORKER-0007` | runtime |  |
| `MAIN-WORKER-0008` | runtime |  |
| `MAIN-WORKER-0011` | runtime |  |
| `MAIN-WORKER-0012` | runtime |  |
| `MAIN-WORKER-0013` | runtime |  |
| `MAIN-WORKER-0014` | runtime |  |
| `MAIN-WORKER-0015` | runtime |  |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| workers and cluster sections with core-based sizing | 1 | 0.4 | `parallelism#contract` |
| workers.run on a lazy thread pool with a bounded queue | 1 | 0.4 | `parallelism#pool` |
| Structured clone and transfer lists | 1 | 0.4 | `parallelism#data` |
| Timeouts, deadlines and signals that terminate stuck threads | 1 | 0.4 | `parallelism#stop` |
| Context in, secrets out, logs through redaction | 1 | 0.4 | `parallelism#boundary` |
| Process pools for code that may crash | 1 | 0.4 | `parallelism#process-pool` |
| Inline fallback and inline test mode | 1 | 0.4 | `parallelism#fallbacks` |
| Documents, imports and the seal boot check on the built-in pool | 1 | 0.4 | `parallelism#builtin` |
| cluster.run with one instance per core on a shared port | 1 | 0.4 | `parallelism#cluster-run` |
| Rolling restarts through the drain | 1 | 0.4 | `parallelism#rolling` |
| Crash respawn with backoff and crash-loop stop | 1 | 0.4 | `parallelism#supervise` |
| Local store and broadcasts relayed by the primary | 1 | 0.4 | `parallelism#shared-state` |
| Cluster-wide resource pressure | 1 | 0.4 | `parallelism#pressure` |

## MainSDK Cloud

Everything on this page is free in your app.


## Quick start

```yaml title="main.yaml"
workers:
  size: auto
  modules: ["src/workers/*.worker.ts"]
```

```ts title="src/workers/sum.worker.ts"
import { worker } from "mainsdk/workers";

export default worker(async (input: number[], ctx) => {
  ctx.log.info("summing", { count: input.length });
  return input.reduce((a, b) => a + b, 0);
});
```

```ts title="lib/total.ts"
import { workers } from "mainsdk";

export async function total(numbers: number[]) {
  return workers.run<number[], number>("./src/workers/sum.worker.ts", numbers, { timeout: "5s" });
}
```

## Configuration

```yaml title="main.yaml"
workers:
  size: auto              # 1 to 64
  queue: 1000             # tasks waiting per pool; beyond it MAIN-WORKER-0007
  timeout: 30s
  memory: 256MB           # heap per thread, or per process with isolation: process
  idle: 30s               # an idle thread exits; the pool starts empty
  env: [TZ, LANG]         # names copied into each thread; never a secret's name
  modules: ["src/workers/*.worker.ts"]
  builtin: on
  pools:
    pdf: { size: 2, isolation: process, memory: 1GB, timeout: 2m, modules: [src/workers/pdf.worker.ts] }
cluster:
  processes: auto         # 1 means no cluster
  start-timeout: 30s
  respawn: { backoff: 1s, max: 30s, crashes: 5, window: 60s }
```

Cores are the machine's, cut to the cgroup CPU quota rounded up, at least 1. `size: auto` is `max(1, floor(cores / processes) - 1)`; `processes: auto` is one per core. A module matches the pool whose `modules` lists it (named pools first), and only declared modules run (`MAIN-WORKER-0002`; in `permissions.mode: observe` it runs and is logged as a would-refuse).

## Calls

`workers.run(module, input, opts)` runs a declared module and returns its result. `opts` takes `transfer` (buffers moved, not copied), `timeout` (at most the pool's timeout, also cut to the caller's deadline) and `signal`. `workers.snapshot()` reports each pool: `size`, `threads`, `busy`, `queued`, `completed`, `failed`, `replaced`.

```ts
import { workers } from "mainsdk";

const buf = new Uint8Array(1024 * 1024);
const out = await workers.run("./src/workers/resize.worker.ts", { buf }, { transfer: [buf.buffer] });
console.log(workers.snapshot().pools);
```

`worker(fn)` defines a worker module's default export. `fn(input, ctx)` gets `ctx.signal` (aborts at the timeout, the deadline or the caller's signal), `ctx.context` (tenant, user, agent, trace, region, env, deadline), `ctx.log` and `ctx.transfer(value, list)` to move buffers back with the result.

```ts
import { worker } from "mainsdk/workers";

export default worker((input: { n: number }, ctx) => {
  const out = new Uint8Array(input.n);
  return ctx.transfer({ out }, [out.buffer]);
});
```

`cluster.run(main)` forks one instance per core in the primary and runs `main` in each instance. `cluster.restart(opts)` replaces instances one at a time and rejects with `MAIN-WORKER-0013` if a new instance is not ready. `cluster.snapshot()` lists the instances and the aggregate. `cluster.role()` is `primary`, `instance` or `single`.

```ts title="start.ts"
import { cluster } from "mainsdk";

declare function startServer(): Promise<void>;   // your HTTP server

await cluster.run(() => startServer());
console.log(cluster.role(), cluster.snapshot().instances.length);
await cluster.restart({ reason: "deploy" });
```

## What you get

- Worker threads for CPU-heavy work such as parsing a large PDF, resizing in pure JavaScript, scoring or compressing, with `workers.run` and a timeout, queue and memory limit per pool.
- Process pools (`isolation: process`) for native modules and code that may crash or leak.
- `cluster`: one instance of your HTTP server per core on one shared port, with rolling restarts.
- Declared-only modules: anything `main.yaml` does not declare is refused.
- The tenant, trace and audit trail cross every thread and process; secrets never do.
- Built-in modules for document parsing and rendering, and optional sanitizing of large inputs.

## What crosses

- **Data.** Input and result are cloned. A value that cannot be cloned fails with `MAIN-WORKER-0003` naming its path, such as `input.items[3].fn`, before anything is sent. Transfer lists detach the buffer in the caller for thread pools. For process pools they are copied and never detached.
- **Context in.** The caller's tenant, user, agent, trace, region, env and deadline are in `ctx.context`. Records the worker writes carry the task's `trace_id`, tenant and module name.
- **Logs out.** Log and audit records go to the main thread, are redacted and are written by its pipeline before the task's result arrives. A worker has no sink of its own.
- **Secrets out.** A worker starts with an empty environment plus the names in `workers.env`. Input that holds a resolved secret fails with `MAIN-WORKER-0004`, and the value is never echoed. An error message passes through log redaction.
- **No other calls.** Inside a worker only `log`, `s`, `schema`, `parse`, `sanitize`, the core units and WebCrypto work. Any other MainSDK call fails with `MAIN-WORKER-0008` naming it.

## Timeouts, crashes and process pools

A task's limit is the smallest of `opts.timeout`, the pool's `timeout` and the time to the caller's deadline. At the limit, or when `signal` aborts, `ctx.signal` aborts; 100 ms later a thread that has not settled is terminated and replaced, and a timed-out task fails with `MAIN-WORKER-0005`. A thread that runs out of memory, calls `process.exit` or crashes fails its task with `MAIN-WORKER-0006` and is replaced; the main thread keeps serving. A full queue fails at once with `MAIN-WORKER-0007` (503, retryable).

A pool with `isolation: process` runs each member as a forked child with SIGKILL in place of thread termination, for native modules and code that may crash or leak. Never use workers for untrusted code: that is the `sandbox` interface.

## Built-in modules

The built-in pool has one thread (two with `workers.pools.builtin.size: 2`) and needs no `workers.modules` entry.

| Module | Used by | Default |
| --- | --- | --- |
| `mainsdk:documents.parse`, `mainsdk:documents.render` | the built-in `documents` reader and renderer | on |
| `mainsdk:imports.xlsx` | the XLSX reader of `imports` | on, when `imports` ships it |
| `mainsdk:seal.hash` | the seal boot check | on, when `seal` ships it |
| `mainsdk:sanitize` | `workers.run("mainsdk:sanitize", { html, profile, maxBytes })` for inputs too big for a request | optional |

## Runtimes

| Runtime | Worker threads | `isolation: process` | Cluster |
| --- | --- | --- | --- |
| Node (long-running) | yes | yes | yes |
| Bun | yes | yes | Linux only; elsewhere one process |
| Deno | yes | runs as threads, logged | one process |
| Serverless Node | yes | runs as threads, logged | one process |
| workerd, Vercel Edge | inline | inline | one process |
| Browser | `MAIN-PORT-0002` | `MAIN-PORT-0002` | `MAIN-PORT-0002` |
| `mainsdk dev` | yes; changed modules reload | yes | one process |
| `createTestApp()` | inline by default | inline by default | one process |

Where threads are missing, tasks run inline in the same process after cloning the input, with the same declaration and refusals, and `MAIN-WORKER-0014` is logged once. The timeout cannot preempt inline code; it is checked when the task awaits. Inline code shares your environment, so the empty-environment rule holds only on real threads.

## Events

Each task is a `mainsdk:workers.run` event with `module`, `pool`, `isolation`, `queuedMs`, `durationMs` and `code`, never the input or result. Pool changes publish `mainsdk:workers.pool` (`start`, `exit`, `replace`, `terminate`) and every run publishes a `kind: "worker"` decision on `mainsdk:permission.decision`.

## Errors and checks

`mainsdk check` reports `MAIN-WORKER-0001` for `node:worker_threads`, `node:cluster` or `child_process.fork` in app code, `MAIN-WORKER-0009` for an invalid `workers` or `cluster` value, `MAIN-WORKER-0010` for a target that cannot run them, and `MAIN-WORKER-0016` for a worker module or `cluster.run` that `main.yaml` does not declare. `mainsdk discover` lists both with file and line.

Runtime refusals are `MAIN-WORKER-0002` to `0008` (see above), `0011` to `0015` for the cluster and `MAIN-PORT-0002` in the browser. Each code's cause and fix is in [check codes](https://mainsdk.dev/docs/reference/check-codes).

## Swapping drivers

`workers` has no driver. Where threads are missing the runtime changes how tasks run: inline after cloning the input, or one process for `cluster`, with the same declaration and refusals (see the runtimes table). A pool's `isolation` (`thread` or `process`) is set in `main.yaml`.

## Testing

`createTestApp()` runs tasks inline. Pass `workers: "threads"` for real threads, or `workers: { threads: false, cores: { available: () => 8 } }` to fake the runtime and the core count.

```ts
import { createTestApp } from "@mainsdk/testing";

const app = createTestApp({ yaml: "schema: 1\nproject: t\nworkers:\n  modules: [src/workers/*.worker.ts]\n", workers: "threads" });
const total = await app.workers.run("./src/workers/sum.worker.ts", [1, 2, 3]);
```

## Helpers for wiring and tests

| Call | What it does |
| --- | --- |
| `cluster(object)` | `cluster` runs one instance per core: `cluster.run`, `cluster.restart`, `cluster.snapshot` and `cluster.role`. |
| `parseWorkers(section, ctx)` | Read the `workers` section of `main.yaml` into settings and issues. |
| `validateWorkers(config, ctx)` | Check parsed workers settings against the app and report issues. |

## Next

- [Sandbox](https://mainsdk.dev/docs/interfaces/sandbox) runs untrusted code, which workers must not.
- [Compute](https://mainsdk.dev/docs/interfaces/compute) covers where the app runs.
- [Testing kit](https://mainsdk.dev/docs/interfaces/testing) has `createTestApp()`.
