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_threadsis the Node-idiom adapter over the same model, and it works: aWorkerestablishes its worker context,parentPortis wired, and messages round-trip. Use whichever fits your code. A third surface, the Web/Bun-style globalWorker(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; prefernode:worker_threadsor 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 noconsole.consoleisundefinedinside the handler, so aconsole.loginonMessageSourcethrows a swallowedReferenceErrorand prints nothing. To observe a worker's output, return a value and log it on the main thread withrequest(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 have | It crosses as |
|---|---|
| An ordinary object/array | structured clone, a fresh copy in the worker |
A SharedArrayBuffer | by reference, same bytes, synchronize with Atomics |
| A function | rejected 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.