Imogen workers
The runtime machinery behind Cruft's worker-hosted compartments: how a worker is spawned from a descriptor, the three lanes a value can take across a thread boundary (SendIR clone-and-rebuild, shared SharedArrayBuffer bytes, and Tier-2 string-arena handles for interned strings), the GC contract with epoch-deferred reclamation, and how worker memory scales sub-linearly.
Node's worker_threads gives real parallelism but charges for it twice: a Worker is a separate V8 isolate, so spawning one is heavy, and everything mutable shared between workers goes through postMessage and structured-clone. The practical result is that workers stay few and chunky because they are expensive, and the serialization boundary is a cost to plan around. Imogen is Cruft's answer to both costs. This page goes beneath the API to the runtime machinery: how worker-hosted Compartments are built, how values cross thread boundaries, and how memory is shared without a shared mutable heap. It assumes Compartments and capabilities.
The problem Imogen answers
JavaScript runtimes have historically offered two worker shapes, each with a known cost:
- Isolate-per-worker (Node
worker_threads, one V8 isolate each): safe, because nothing is shared, but each worker duplicates an entire VM heap and runtime machinery, which is expensive at high worker counts. - One shared mutable heap across threads: cheap to share, and a data-race generator, because object identity, prototype mutation, and GC reachability all become cross-thread concerns.
Imogen is a deliberate third shape. Its rule fits in three lines:
Mutable object identity is local.
Shared bytes are explicit.
Immutable strings are shared through a handle; everything else is copied.
Isolation is kept at the realm/runtime ownership layer rather than the whole-VM layer, so many isolated workers coexist without multiplying the VM surface per tenant.
How a worker is born: the descriptor
new Compartment({ worker: true, ... }) does not move a runtime object to another thread, runtimes are thread-local by construction and never cross. Instead, the main thread sends a descriptor: a plain, Send-safe recipe carrying the initial globals, the boundary policy, the onMessageSource text, and module-loader configuration. The worker thread then builds everything locally from that recipe, its own Runtime, its own intrinsics, its own realm and globalThis.
This is why onMessageSource must be a string of source rather than a function value: a main-thread closure captures main-thread heap objects and cannot lawfully cross. The worker compiles the handler source inside its own runtime, so nothing thread-unsafe crosses the thread boundary. The API shape reflects the safety model directly.
Crossing the boundary: three lanes
Every value that moves between workers takes exactly one of three lanes, chosen by what the value is.
Lane 1: SendIR (the default, clone and rebuild)
Ordinary objects, arrays, and records are lowered to SendIR, a portable intermediate representation; the IR crosses the thread; the receiver rematerializes a fresh, equivalent object graph in its own heap:
- LowerThe sender's object graph is lowered to a flat send-IR.
- SendThe send-IR crosses the thread boundary.
- RematerializeThe receiver rebuilds a fresh, equivalent object graph in its own heap.
The receiver's handler gets a Web-style event whose .data is the rebuilt value. Identity does not survive the trip, the receiver's object is a new object, which is exactly the point: no reference into another worker's mutable heap ever exists.
Values that cannot be lowered (functions, live host objects) are rejected at send time with a clear error. That rejection is a deliberate part of the safety model: a function is realm-local authority, and authority crosses only by explicit grant, never by smuggling.
Lane 2: shared bytes (SharedArrayBuffer)
A SharedArrayBuffer in a payload is the one thing that crosses by reference: the receiver's object points at the same backing memory.
const sab = new SharedArrayBuffer(8);
const main = new Int32Array(sab);
const c = new Compartment({
worker: true,
onMessageSource: `(e) => {
const ta = new Int32Array(e.data);
Atomics.add(ta, 0, 100);
}`,
});
Atomics.store(main, 0, 5);
c.send(sab); // worker and main now see the same 8 bytes
The lane is deliberately narrow: bytes only, accessed through typed-array views, synchronized by Atomics. Mutable sharing exists in Imogen, but only where you explicitly asked for it, and only in a representation (raw bytes) where "shared and mutable" has well-defined semantics. Do not treat it as a general object transport; if data has object shape, it belongs in lane 1 or 3.
Lane 3: the Tier-2 string arena
Strings are worth sharing rather than copying, and because a string is immutable it is safe to share without synchronization. Imogen gives them a home: the Tier-2 string arena, a shared store of interned strings addressed by handle (Tier2Handle). This is the only immutable-value lane; frozen objects and arrays are not shared, they copy through SendIR (Lane 1) like any other object.
The lane has two properties:
- Strings are leaves. A string references nothing else, so the arena holds no graph and can never form a cycle. Plain atomic reference counting is therefore complete for Tier-2, no cycle collector needed for that lane.
- Opaque to the ordinary GC. A worker's heap holds a Tier-2 string through an opaque external handle that the tracing collector treats as a leaf. The two memory systems couple only at the boundary: when a heap sweep reclaims a handle slot, the runtime drains it and applies the Tier-2 decrement.
The GC contract
Imogen's memory rules, stated as the contract the runtime enforces:
- Each runtime/realm owns and collects its ordinary mutable heap independently, a collection in one worker never stops another.
- No direct cross-worker references to ordinary mutable objects, ever.
- Tier-1 heaps hold Tier-2 data only through opaque external handles.
- Handle reclamation at sweep time drains into Tier-2 refcount decrements.
- A Tier-2 refcount hitting zero does not free immediately, the handle retires into an epoch-deferred free list and is reclaimed only after a two-epoch grace period.
Rule 5 deserves the extra sentence: a send may be in flight when the last on-heap reference drops. Freeing at zero would let the arena reclaim data while another worker is still rematerializing a handle from a message. Epoch retirement converts "zero references now" into "eligible to free once all in-flight boundary work has cleared."
What this buys: the scaling profile
Because ordinary heaps stay local (and collectible) while the shared lanes don't duplicate per worker, memory scales sub-linearly in worker count and stays bounded under sustained load, the collector runs between worker request/replies, so a busy pool converges to a plateau instead of growing. Measured on a webhook workload with 2 KB payloads:
| workers | steady RSS |
|---|---|
| 64 | ~350 MB |
| 256 | ~420 MB |
| 1024 | ~580 MB |
Sixteen times the workers costs well under twice the memory, because most of the cost is the fixed per-realm baseline, not per-worker VM duplication. The CRUFT_GC_HEADROOM knob (default 4.0) applies to every worker heap and trades RSS for throughput across the whole pool.
Choosing a transport: the developer rule
When deciding how a value should cross (or whether it should):
| Value kind | Correct path |
|---|---|
| Ordinary object / array / record | SendIR clone and rematerialize |
| Function / host callback | Don't, grant an explicit bridge in the target realm |
| Mutable bytes | SharedArrayBuffer |
| Immutable string worth sharing | Tier-2 string-arena handle |
| Immutable (frozen) object / array | SendIR clone (copied, not shared) |
| Realm-local capability | Never crosses; grant explicitly on the other side |
The classification question is always "what is this value?", and each answer has exactly one lawful lane. That one-lane-per-kind property is what makes Imogen programs data-race-free for ordinary objects by construction rather than by care.
Using workers
The usage-level API reference, spawning, message/shared-memory transports, and the relationship between node:worker_threads and Compartment({ worker: true }), is on the Workers page. node:worker_threads is functional: a Worker loaded from a module file round-trips a structured-cloned message through parentPort in both directions. Constructing a Worker from an inline source string with { eval: true } is not supported yet, so point the worker at a module file.
One current limitation to keep in mind: worker-side console.log is not surfaced to the parent process's stdout. A worker's onMessageSource handler runs, but anything it logs is not visible on the main thread — observe its results over an explicit transport (a SharedArrayBuffer synchronized with Atomics, or a parentPort message) rather than through the console.