Workers

How to run work on other threads with worker-hosted compartments, new Compartment({ worker: true }). Covers spawning a worker from handler source text, send versus request, structured-clone message passing, sharing memory through a SharedArrayBuffer, and the node:worker_threads adapter over the same model.

Cruft runs work on other threads through worker-hosted compartments, new Compartment({ worker: true }). This is Cruft's real, working worker API, built on the Imogen model. This page is the usage reference; the subsystem page covers the mechanics.

Ways to run a worker. Compartment({ worker: true }) is Cruft's native worker API. node:worker_threads is the Node-idiom adapter over the same model, and it works: a Worker establishes its worker context, parentPort is wired, and messages round-trip. Use whichever fits your code. A third surface, the Web/Bun-style global Worker (new Worker(url), postMessage/onmessage), is also projected over the same substrate and loads its worker from a file, but it is the newest of the three and its message round-trip is not yet reliable; prefer node:worker_threads or the Compartment API today.

Spawning a worker

A worker-hosted compartment runs its realm on a separate thread. You give it a message handler as source text (compiled on the worker), and talk to it with send:

const c = new Compartment({
  worker: true,
  onMessageSource: `(e) => "worker got: " + JSON.stringify(e.data)`,
});
console.log(await c.request({ task: "process", n: 42 }));
// worker got: {"task":"process","n":42}

onMessageSource is a string, compiled on the worker, on purpose: a main-thread closure captures main-thread heap objects and cannot cross the boundary. The worker compiles the handler in its own realm, so nothing thread-unsafe makes the trip. The handler receives a Web-style event whose .data is the delivered payload.

The worker realm has no console. console is undefined inside the handler, so a console.log in onMessageSource throws a swallowed ReferenceError and prints nothing. To observe a worker's output, return a value and log it on the main thread with request (as above), rather than logging inside the worker.

Getting results back

send is fire-and-forget (returns undefined, no reply channel). For request/reply, use request(payload), which returns a Promise that resolves with the worker handler's return value:

const c = new Compartment({
  worker: true,
  onMessageSource: `(e) => e.data * 2`,
});
await c.request(21);   // 42 — the worker computes and replies

// a parallel pool: many requests in flight at once
const c2 = new Compartment({ worker: true,
  onMessageSource: `(e) => e.data.reduce((a,b) => a+b, 0)` });
await Promise.all([[1,2],[3,4],[5,6]].map(chunk => c2.request(chunk)));
// [3, 7, 11]

The payload always arrives on e.data, never on e itself, so summing a delivered array is e.data.reduce(...), exactly as the e.data * 2 handler above reads its number off e.data.

Use send when you don't need a reply (dispatch work, fire an event); use request when you want the result back.

Both send and request structured-clone payloads across the boundary, no shared mutable objects. A value that can't be cloned (a function) is rejected at send time. Identity does not survive the trip: the worker gets a fresh copy, which is exactly what keeps each worker's heap its own.

Sharing memory explicitly

The one thing that crosses by reference is a SharedArrayBuffer, synchronized with Atomics:

const sab = new SharedArrayBuffer(8);
const view = new Int32Array(sab);
const c = new Compartment({
  worker: true,
  onMessageSource: `(e) => {
    const ta = new Int32Array(e.data);
    Atomics.add(ta, 0, 100);
  }`,
});
Atomics.store(view, 0, 5);
c.send(sab);
// after the worker runs, main sees 105 through the same buffer

This is the deliberate, narrow mutable-sharing lane: raw bytes only, Atomics for synchronization. Everything with object shape goes through the structured-clone lane above.

The three transports, at a glance

You haveIt crosses as
An ordinary object/arraystructured clone, a fresh copy in the worker
A SharedArrayBufferby reference, same bytes, synchronize with Atomics
A functionrejected at send (functions don't cross)

This is the Imogen rule: mutable identity is local, shared bytes are explicit, immutable values are mediated. It's why worker compartments are cheap enough to spawn by the thousands while staying data-race-free by construction for ordinary objects.

Using node:worker_threads

Cruft's worker model is Imogen, and node:worker_threads is the Node-idiom adapter over it. A Worker establishes its worker context, parentPort posts and receives messages, and a basic message round-trip works. If you control the code you can reach for Compartment({ worker: true }) directly for the native API instead. Broader package compatibility (piscina, workerpool, jest/vitest worker pools) depends on the exact slice of the worker_threads surface each library uses, so verify your specific dependency before relying on it.

Capabilities

A worker compartment inherits the process capability mode: under --sealed/ --sealed-deps, a worker's I/O is gated the same way the main thread's is, and the worker realm starts with the same empty-global discipline as any compartment. See Compartments and capabilities.

Source: new Compartment({ worker: true }) (runtime intrinsic); the mechanics in Imogen workers.