MainSDK
Search⌘K
Interfaces

Platform, OS and network

Worker threads and cluster

workers

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.

Building for 0.4ships as a beta APIPhase 1 · Core, public betaSpec parallelismFree in your app, Apache-2.0

Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.

Stability The promise an interface ships with.

  • Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
  • Beta Usable in production; may change before it becomes stable.
  • Experimental May change or go away; needs an explicit opt-in in main.yaml.
Stability levels
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.

At a glance

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 }

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)

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

1 drivers. Select one to see its line in main.yaml.

main.yamlBuilt in (default)
workers:
  driver: builtin

Change this one line to swap providers; your calls stay the same.Built-in and local drivers integration

Codes

CodeWhere it appearsMeaning
MAIN-WORKER-0001Checkmainsdk check, before deploynode:worker_threads, node:cluster or child_process.fork used outside the SDK
MAIN-WORKER-0009Checkmainsdk check, before deployA 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-0010Checkmainsdk check, before deployWorkers 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-0016Checkmainsdk check, before deployA worker module or cluster.run found in the code that main.yaml doesn't declare
MAIN-WORKER-0002RuntimeError responses and logsSee the spec
MAIN-WORKER-0003RuntimeError responses and logsSee the spec
MAIN-WORKER-0004RuntimeError responses and logsSee the spec
MAIN-WORKER-0005RuntimeError responses and logsSee the spec
MAIN-WORKER-0006RuntimeError responses and logsSee the spec
MAIN-WORKER-0007RuntimeError responses and logsSee the spec
MAIN-WORKER-0008RuntimeError responses and logsSee the spec
MAIN-WORKER-0011RuntimeError responses and logsSee the spec
MAIN-WORKER-0012RuntimeError responses and logsSee the spec
MAIN-WORKER-0013RuntimeError responses and logsSee the spec
MAIN-WORKER-0014RuntimeError responses and logsSee the spec
MAIN-WORKER-0015RuntimeError responses and logsSee the spec

Features

Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.

FeaturePhaseMilestoneSpec ticket
workers and cluster sections with core-based sizingPhase 10.4parallelism#contract
workers.run on a lazy thread pool with a bounded queuePhase 10.4parallelism#pool
Structured clone and transfer listsPhase 10.4parallelism#data
Timeouts, deadlines and signals that terminate stuck threadsPhase 10.4parallelism#stop
Context in, secrets out, logs through redactionPhase 10.4parallelism#boundary
Process pools for code that may crashPhase 10.4parallelism#process-pool
Inline fallback and inline test modePhase 10.4parallelism#fallbacks
Documents, imports and the seal boot check on the built-in poolPhase 10.4parallelism#builtin
cluster.run with one instance per core on a shared portPhase 10.4parallelism#cluster-run
Rolling restarts through the drainPhase 10.4parallelism#rolling
Crash respawn with backoff and crash-loop stopPhase 10.4parallelism#supervise
Local store and broadcasts relayed by the primaryPhase 10.4parallelism#shared-state
Cluster-wide resource pressurePhase 10.4parallelism#pressure

Quick start

main.yaml
workers:
  size: auto
  modules: ["src/workers/*.worker.ts"]
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);
});
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

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.

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.

ModuleUsed byDefault
mainsdk:documents.parse, mainsdk:documents.renderthe built-in documents reader and rendereron
mainsdk:imports.xlsxthe XLSX reader of importson, when imports ships it
mainsdk:seal.hashthe seal boot checkon, when seal ships it
mainsdk:sanitizeworkers.run("mainsdk:sanitize", { html, profile, maxBytes }) for inputs too big for a requestoptional

Runtimes

RuntimeWorker threadsisolation: processCluster
Node (long-running)yesyesyes
BunyesyesLinux only; elsewhere one process
Denoyesruns as threads, loggedone process
Serverless Nodeyesruns as threads, loggedone process
workerd, Vercel Edgeinlineinlineone process
BrowserMAIN-PORT-0002MAIN-PORT-0002MAIN-PORT-0002
mainsdk devyes; changed modules reloadyesone process
createTestApp()inline by defaultinline by defaultone 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.

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

CallWhat 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

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet