Imogen: the shared-heap architecture
Imogen is the memory model underneath Cruft's worker compartments, deciding what many concurrent units of JavaScript share and what stays private: mutable object identity stays local, shared bytes are explicit through SharedArrayBuffer, and immutable strings are deduplicated through a Tier-2 string arena. Everything else copies across a worker boundary. This page covers the heaps and how the collector treats each.
Imogen is the memory model underneath Cruft's worker compartments. It answers one question: when many isolated units of JavaScript run at once, what memory do they share, and what stays private? Its answer is a deliberate middle path between the two shapes runtimes usually pick from, and this page is about that model and how it is built.
Where Imogen workers documents the developer-facing worker surface and how a value crosses a thread boundary, this page is about the heap itself: which heaps exist, what a shared value physically is, and how the collector treats each. It assumes Compartments and capabilities and pairs with the garbage collector.
The rule
Most runtimes offer one of two memory shapes. An isolate per worker (Node's worker_threads) shares nothing, which is safe but duplicates a whole VM heap for every worker. One mutable heap across all threads shares everything, which is cheap but makes object identity, prototype mutation, and reachability into cross-thread data races. Imogen is a third shape, and its whole model fits in three lines:
Mutable object identity is local.
Shared bytes are explicit.
Immutable strings are shared through a handle; everything else is copied.
Ordinary JavaScript objects stay local to the heap that owns them. The only things that cross a worker boundary as genuinely shared memory are byte buffers and immutable strings, each through its own lawful lane. Everything else, including frozen objects and arrays, is copied and rebuilt in the receiver. Isolation lives at the realm-and-runtime ownership layer, not the whole-VM layer, so many workers coexist without each one carrying a full duplicate VM.
The heaps and lanes
There is one mutable heap per realm, and two shared lanes that every realm can reach into. The lanes are the only shared memory in the system.
The distinction is load-bearing, because it decides how a value moves:
A function, a live host object, or any other non-cloneable value is rejected when a payload is lowered for sending. That rejection is part of the model, not a gap: a closure captures heap objects in its own realm, and there is no lawful way to make those cross-thread, so the boundary refuses rather than aliasing them.
Crossing without sharing: SendIR
The default way a value moves between realms is copy and rebuild. The sender lowers its object graph into a portable representation, that representation crosses the thread, and the receiver rematerializes a fresh, equivalent graph in its own heap.
- SendIR | cross-thread send | crosses the boundary
- receiver runtime | rematerialize | a fresh, equivalent graph in the receiver's heap
The two graphs are independent afterward: mutating one does not touch the other, because they are different objects in different heaps. This is what keeps mutable identity local while still letting data move.
The Tier-2 string arena
The one immutable value shared rather than copied across a worker boundary is the string. A shared string is interned into the Tier-2 arena: stored once, frozen, and addressed by a handle (Tier2Handle). Many realms can hold the same string without duplicating its bytes. A realm's ordinary heap holds that handle as an opaque external leaf: the mark-and-sweep collector sees the handle, but never traces into the string behind it. That gives a clean ownership split between the two tiers.
Strings are leaves: they hold no references to other values, so the arena has no graph to trace and can never form a cycle. Reference counts are therefore complete on their own, and the arena needs no separate cycle collector the way the mutable heap does. Frozen objects and arrays do not enter Tier-2; they cross a worker boundary by being copied through SendIR like any other object.
How the two tiers couple
The two collectors run independently and meet at exactly one seam. A realm's mark-and-sweep is a Tier-1 concern; the arena's reference counting is a Tier-2 concern. They touch only when a Tier-1 sweep reclaims a slot that held an external handle:
- Tier-1 mark-and-sweep collects each realm's heap on that realm's own schedule; a pause in one realm does not stop the others.
- When sweep reclaims an external-handle slot, the runtime drains that handle and applies the matching Tier-2 reference-count decrement at the boundary.
- A Tier-2 count reaching zero does not free immediately. The handle is retired onto an epoch-deferred free list, and the actual free waits out a two-epoch grace period.
The grace period is not caution for its own sake. A send can be in flight: another worker may be partway through rematerializing a handle at the moment its count hits zero. Freeing immediately would let the arena reclaim a value while it is still being read. Epoch retirement turns "zero references right now" into "safe to free once all in-flight boundary work has cleared."
Why it scales
Because ordinary heaps stay local and collectible, and only bytes and interned strings are ever physically shared, adding a worker does not add a whole VM. The shared lanes are paid for once regardless of how many realms reach into them, and the per-realm cost is just that realm's own live objects. This is why Cruft's worker memory profile grows sub-linearly in worker count rather than duplicating the engine per tenant, which is the practical reason the model exists.
Limitations
- No shared mutable objects, by design. You cannot hand another realm a live, mutable JavaScript object and see edits on both sides. If you need shared mutation, it has to be bytes in a
SharedArrayBuffer, with your ownAtomicssynchronization. This is a deliberate exclusion, not a missing feature. - The byte lane is unsynchronized.
SharedArrayBuffershares storage but not ordering; correct concurrent access is the program's responsibility. - Only immutable strings are shared, not immutable objects. A frozen object or array is copied through SendIR like any other value; there is no shared-immutable- object lane. To share a large immutable structure without copying it per worker, encode it into a
SharedArrayBuffer.