Compartments vs. workers
A Compartment and a worker Compartment are the same primitive with one difference, the thread the realm runs on. That single choice decides what is isolated (authority, or authority and the heap), how you talk to the code (a return value, or messages), and what it costs. This page is the side by side and a short rule for picking one.
A worker is not a different primitive from a Compartment. It is a Compartment with one option flipped. Everything else about the two, the empty realm, the capability grant, the time budget, is shared. The single thing that changes is the thread the realm runs on, and that one choice is what everything else follows from.
The one difference
A Node Worker and a Cruft Compartment both run code somewhere else, but they draw the isolation line on different axes. A worker isolates the heap and shares your authority. A plain Compartment shares the heap and isolates authority. A worker Compartment does both: it runs on its own thread with its own heap, and it still only sees the capabilities you grant it.
So the choice between a plain Compartment and a worker Compartment is really the choice of what you are isolating:
- Authority only, on the calling thread: a plain
Compartment. - Authority and the heap, on its own thread: a
Compartmentwithworker: true.
A plain Compartment: isolation on the calling thread
The realm runs on the thread that created it. It is a fresh globalThis on an already running engine, reached synchronously: you evaluate source in it and get the completion value back, no message-passing and no thread hop. (What that realm costs to build in the current release is a separate question, covered in the note below.)
const c = new Compartment({ globals: { greet: (n) => "hi " + n } });
c.evaluate('greet("ada")'); // "hi ada", returned on this thread
Two properties make it a real boundary and not a convention:
- The
globalsgrant is the only way in. Anything you do not place inglobalsis simply absent inside the realm, so ambient authority never leaks. A granted object is deep-copied across the boundary, so a data grant is a copy, not a shared channel. When you need shared, observable state, grant a function. timeout_mscannot be caught. The interrupt fires beneath the language, so a tenant cannot wrap a runaway loop intry/catchand outlast its budget.
A note on cost in v0.0.10. In principle a same-thread Compartment should be a very cheap realm, since it could share the calling thread's intrinsics. In the current release it does not: eachnew Compartment()deep-clones its own copy of the realm intrinsics (itsObject,Array,RegExp,Date,JSON,Temporal, andIntlmachinery), so a plain Compartment costs on the order of a couple of megabytes, and holding many of them at once is presently expensive in both memory and construction time. This is an implementation limitation, not a property of the model, and it is planned to be resolved in a later version by sharing the immutable intrinsics across same-thread realms. Until then, if you need many isolated realms held live at once, prefer worker Compartments, whose per-realm cost is bounded and reclaimed (see below).
A worker Compartment: isolation on its own thread
Pass worker: true and the realm is spawned on its own OS thread with its own mutable heap, which collects independently of the main thread. Nothing mutable is shared across the thread, so the ways you talk to it are explicit.
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 details all follow from the thread boundary:
- The handler is source text, not a closure.
onMessageSourceis a string, compiled on the worker, because a main-thread function captures main-thread heap objects that cannot lawfully cross a thread boundary. - You talk to it by message.
send(payload)is fire-and-forget;request(payload)returns a promise that resolves with the worker's reply.sendon a non-worker Compartment is a no-op. - Payloads are structured-cloned. The handler receives a Web-style event whose
.datais the re-materialized payload. A value that cannot be cloned, such as a function, is rejected atsendtime. - A
SharedArrayBufferis the exception. It crosses by reference, so the worker and the main thread see the same bytes, synchronized withAtomics. It is the one payload that is shared rather than copied.
Each worker is a full realm on its own thread, so memory grows with the worker count, but sub-linearly and bounded: the collector runs between requests, and RSS converges to a plateau rather than climbing without end. Measured on a webhook workload with 2 KB payloads:
| workers | steady RSS |
|---|---|
| 64 | ~350 MB |
| 256 | ~420 MB |
| 1024 | ~580 MB |
Most of that is the fixed per-realm cost; the per-request churn on top is small and reclaimed. Size a memory-capped container to the steady RSS for your worker count, and trade memory for throughput with CRUFT_GC_HEADROOM if you need to.
What crosses the boundary
| Plain Compartment | Worker Compartment | |
|---|---|---|
| Realm runs on | the calling thread | its own OS thread |
| Isolates | authority | authority and the heap |
| You give it code as | source you evaluate | an onMessageSource string |
| You get results by | the return value of evaluate | request (reply) or send (fire-and-forget) |
| Grants / payloads | deep-copied in via globals | structured-cloned per message |
| Shared, observable state | grant a function (a membrane) | a SharedArrayBuffer plus Atomics |
| Time budget | timeout_ms, uncatchable | timeout_ms on the reply |
| Marginal cost (v0.0.10) | ~2 MB, deep-cloned intrinsics, not yet reclaimed | ~0.17 MB on its own thread, reclaimed |
Which to reach for
Reach for a plain Compartment when the work is synchronous and you want a value back, and when you are sandboxing a bounded region of untrusted code in-process: a plugin, a config expression, a dependency's callback. In v0.0.10, keep the number you hold live at once modest (see the cost note above); for high-cardinality isolation, reach for workers instead until the intrinsic-sharing work lands.
Reach for a worker Compartment when the work is CPU-bound or long-running and you do not want it blocking the calling thread, when you want many jobs running at once in parallel, or when you need heap isolation so that a leak or a crash in one unit of work cannot perturb its neighbors. You will communicate by message and share explicitly through a SharedArrayBuffer.
What this is not
A worker Compartment is not one heap running on many threads. Each worker holds its own thread-local heap, and values cross by copy. That is the same architecture class as Node worker_threads, and it is deliberate: per-thread heaps are what keep memory bounded and keep ordinary objects free of data races by construction.
Nor is a worker faster per thread. Cruft's execution core is an interpreter, so a single worker is slower at raw compute than a JIT-backed runtime. The win is not one fast thread; it is running many isolated units at once, cheaply. Reach for workers for memory-cheap concurrency at high cardinality, not for single-threaded speed.