Workers
Cruft runs real multi-threaded work as worker compartments, which are realms over a per-thread heap rather than full VM isolates, so you can spawn thousands of them where Node's worker_threads would need gigabytes. This page makes the case, shows a parallel work pool, and points to the full API and the memory model underneath.
Node's worker_threads gives you real OS threads, and it works, but it charges for parallelism twice. Every Worker is a separate V8 isolate, so each one duplicates a whole engine heap and its machinery, and everything mutable you share between threads is copied through postMessage. The practical ceiling is low: workers stay few and chunky because each is expensive, and a pool of a thousand is measured in gigabytes.
Cruft's workers are the same idea with the cost removed. A worker is a Compartment running on another thread: its own realm and its own heap, but not a duplicated virtual machine. The isolation lives at the realm boundary, not the whole-VM boundary, so a worker is cheap enough to spawn by the thousands. That single change, realms over per-thread heaps instead of isolates, is what turns workers from a heavyweight tool you ration into an everyday unit of concurrency.
The win, in numbers
Because ordinary heaps stay local and collectible while the shared data does not duplicate per worker, memory scales sub-linearly in the worker count. One cruft process running a webhook-transform load with 2 KB payloads, from a single worker to 131,072:
| workers | peak memory | wall time |
|---|---|---|
| 1 | 61 MiB | 0.3 s |
| 2,048 | 260 MiB | 0.5 s |
| 8,192 | 750 MiB | 2.5 s |
| 32,768 | 2.32 GiB | 7.9 s |
| 65,536 | 3.15 GiB | 18.5 s |
| 131,072 | 3.89 GiB | 65.5 s |
The marginal cost is roughly 0.03 MiB per worker at the tail: the fixed per-realm baseline dominates, not a per-worker VM, and the ceiling is machine RAM rather than anything in the engine. Node's isolate-per-worker model duplicates a full engine heap for each one, so the same fan-out runs into gigabytes and stalls in the low thousands. This is what to reach for workers on Cruft for: memory-cheap concurrency at high cardinality. A single worker is no faster today (the compute tiers are young, see Limitations); the win is running many of them at once.
A worker is a compartment on a thread
A worker runs a message handler you give it as source text, and you talk to it with request (reply) or send (fire-and-forget):
const c = new Compartment({
worker: true,
onMessageSource: `(e) => e.data.reduce((a, b) => a + b, 0)`,
});
await c.request([1, 2, 3, 4]); // 10 — computed on another thread
The handler is a string, compiled on the worker, because a main-thread closure captures main-thread heap objects that cannot lawfully cross a thread boundary. The worker builds its own realm from your recipe and compiles the handler there, so nothing thread-unsafe ever crosses.
That constraint is what makes a parallel pool natural: hand each worker a chunk of work and await them all at once.
// Fan a batch of jobs across a pool, collect the results.
const pool = Array.from({ length: 8 }, () =>
new Compartment({
worker: true,
onMessageSource: `(e) => e.data.map((n) => n * n).reduce((a, b) => a + b, 0)`,
}),
);
const batches = [[1, 2, 3], [4, 5, 6], [7, 8, 9] /* … */];
const results = await Promise.all(
batches.map((batch, i) => pool[i % pool.length].request(batch)),
);
Each request runs on its worker's own thread and heap, in parallel, and the results come back as ordinary cloned values. No lock, no shared mutable state, no data race to reason about, because by default nothing is shared.
What crosses, and how
A value moving between threads takes exactly one of three lanes, chosen by what it is:
The default is copy, so ordinary data is data-race-free by construction. The one mutable lane, a SharedArrayBuffer guarded by Atomics, is explicit and narrow: raw bytes only, exactly where "shared and mutable" has well-defined semantics. The memory model that makes this cheap, and how the collector keeps each worker's heap independent, is Imogen.
Two ways to write one
The native API is new Compartment({ worker: true }). Node's node:worker_threads is an adapter over the same model: a Worker establishes its context, parentPort posts and receives, and a message round-trip works. Use the Node idiom to port existing code; reach for the Compartment directly when you control it and want the native surface. Cruft also projects a Web/Bun-style global Worker (new Worker(url), postMessage/onmessage, loading its worker from a file) over the same substrate, but it is the newest surface and its round-trip is not yet reliable, so prefer the other two today. The full method-by-method reference, send versus request, payload shapes, and the SharedArrayBuffer lane, is on the Worker API page.
Capabilities carry across
A worker is still a compartment, so it inherits the process capability mode. Under --sealed or --sealed-deps a worker's I/O is gated exactly as the main thread's is, and the worker realm starts with the same empty globals as any compartment: authority reaches it only by explicit grant. Parallelism does not open a side door around the isolation model.
Limitations
- Per-worker compute is interpreter-speed today. A single worker runs on the same young execution tiers as the main thread, so the reason to fan out is memory-cheap concurrency and I/O overlap, not a faster core per worker. The optimizing tier that would close the compute gap is future work.
node:worker_threadscoverage is partial. The message round-trip andparentPortwork, but a pooling library (piscina, workerpool, a test runner's worker pool) uses its own slice of the surface. Verify your specific dependency before relying on it.- The worker realm has no
console.consoleisundefinedinside the handler; return a value and log it on the main thread rather than logging inside the worker. - The handler is source text, not a closure. You cannot capture main-thread variables into
onMessageSource; pass what the worker needs in the payload.