Trash Panda
Trash Panda is Cruft's garbage collector, a per-realm mark-and-sweep collector, so the main program and every worker own a heap collected independently. This page covers its design, the adaptive trigger, the two tuning knobs (CRUFT_GC_HEADROOM and CRUFT_GC_TARGET_MB), the irreducible per-worker memory floor, and the measured scaling behavior.
Garbage collection has the same broad shape in every runtime that has one: memory is allocated freely and never explicitly freed, a collector periodically reclaims whatever is no longer referenced, and that reclamation costs an occasional pause. On a long-running process, memory climbs and then falls back as the collector runs; a real leak is memory that never comes back down; and in most runtimes a single heap is shared across the whole process, so one leaky corner becomes everyone's problem.
Cruft's collector is called Trash Panda, and it is a tracing mark-and-sweep collector, so that model mostly carries over. Two things are different: there is no gc() to call and no way to disable it, and the heap is per-realm rather than one shared space. This page covers the collector's design, its interaction with the event loop and workers, the tuning surface (its two knobs), and the measured behavior behind the claims. It pairs with Imogen workers, which owns the cross-worker memory story.
Design shape
Trash Panda is a two-tier collector. The tiers hold different kinds of value, run on different disciplines, and meet at exactly one narrow point:
- Tier 1, the per-realm mutable heaps, is what the mark-and-sweep collector this page describes actually traces.
- Tier 2, the shared immutable arena, is not traced at all. It is reference-counted, and Trash Panda reaches it only through opaque handles.
Tier 1: per-realm mutable heaps
There is no single process-wide JavaScript heap. The main program and every worker compartment own an ordinary mutable heap, collected independently: a collection in one realm never stops the others. This is a direct consequence of the Imogen rule that mutable object identity is local. Since no realm can reference another realm's mutable objects, each heap's reachability graph is self-contained and can be traced without cross-thread coordination.
Tier 2: the shared immutable arena
The one shared memory system, the Tier-2 immutable arena, is deliberately not traced by the mark-and-sweep collector. A mutable heap holds Tier-2 values through opaque external handles; the tracer treats those handles as leaves and never follows them into the arena. Reclaiming a handle slot at sweep time drains into an atomic refcount decrement on the arena side. So the two tiers run on different disciplines, mark-and-sweep for the mutable heaps and reference counting for the immutable arena, and they couple at exactly one place: a swept handle slot becomes a refcount decrement. Two memory systems, one narrow coupling point.
Weak collections
WeakMap uses ephemeron semantics: a value is kept alive only while its key is independently reachable, resolved to a fixpoint so a live value can in turn keep another key's value alive. Standalone WeakRef and FinalizationRegistry are a separate matter but are genuinely weak: a WeakRef does not keep its target alive, its deref() returns undefined once the target has been collected, and a FinalizationRegistry enqueues its cleanup callback when the registered target dies.
The adaptive trigger
After each collection, the engine sets the next trigger at roughly 4× the live object count (the headroom multiplier, see below; earlier builds used a 2× baseline). The effect is that collection frequency adapts to the program's actual behavior:
- A program with a small, stable live set collects rarely.
- An allocation-churny program collects often, and reclaimed slots are reused before the heap grows.
Steady-state RSS therefore tracks the live working set, not the total ever allocated: a loop that creates and drops 2,000,000 short-lived objects holds flat RSS.
Safepoints: where collection happens
The collector runs at safe points, which exist at two granularities:
- Between bytecode operations, so allocation-heavy straight-line code is collectible mid-function.
- Between jobs: promise reactions, timer callbacks, worker request/replies. This is the one that matters for servers: a long-running async program that keeps the job queue busy is still collected between turns, which is why a worker pool under sustained load converges to an RSS plateau instead of climbing.
Safepoints impose a discipline on the runtime's own code: any engine-internal value that lives across a safepoint must be rooted (registered as reachable), or the collector may reclaim it mid-operation. Rooting bugs are the classic manual-rooting failure mode, which is why the diagnostic CRUFT_GC_STRESS mode exists: it forces a collection at a bounded allocation cadence, turning any missing-root bug from a rare heisenbug into a deterministic crash. The conformance and worker batteries are run under stress mode as a standing soundness lane.
The tuning surface
CRUFT_GC_HEADROOM, memory vs. throughput
The headroom multiplier (default 4.0) scales how much a heap may grow between collections. It applies to the main heap and every worker heap.
| Value | Effect |
|---|---|
4.0 (default) | Balanced; RSS tracks the working set. |
lower, floor 1.1 | Collect sooner: lower RSS plateau, some throughput cost. |
| higher | Collect later: higher plateau, more throughput on churny code. |
Values are clamped to the 1.1× floor, so a mistuned value cannot force collect-on-every-allocation. Measured on a churn loop (100k promises created and dropped per round), steady RSS:
| headroom | steady RSS |
|---|---|
| 1.2 – 2.0 | ~210 MB (working-set floor) |
| 4.0 | ~300 MB |
| 8.0 | ~670 MB |
| 16.0 | ~1125 MB |
Below the working set the knob has no effect, you cannot collect what is live. Above the default, it buys throughput linearly in memory.
Notably, in the measured worker-pool sweeps the knob moves RSS far more than it moves latency (1024 workers: p99 stays in the same band from headroom 1.5 to 4.0). For latency-sensitive services the default is usually right; drop to 1.5 only when RSS is the binding constraint.
CRUFT_GC_TARGET_MB, a soft memory target
Instead of picking a fixed headroom, you can state the goal directly:
CRUFT_GC_TARGET_MB=8000 cruft server.mjs # keep RSS near 8 GB
Modeled on Go's soft GOMEMLIMIT: while whole-process RSS is under 50% of the target, the runtime uses the full configured headroom (favor throughput). From 50% to 100% of the target, the effective headroom scales smoothly down to the 1.1× floor, so collections tighten as pressure rises and reclaimable memory self-bounds near the target. Above the target it holds the floor. The variable is read process-wide, so every worker heap in a pool tightens together.
Measured against a churn loop that reaches ~667 MB untargeted: a 350 MB target holds ~300 MB; a 250 MB target holds the ~210 MB working-set floor.
Understand what it bounds: reclaimable memory, garbage. It cannot push RSS below the live working set, and it does not shrink the fixed per-realm cost of a worker pool. Use it as a safety cap against churn-driven growth and OOM, not as a way to fit more workers into less memory.
CRUFT_GC_STRESS, the soundness diagnostic
Forces maximal collection frequency. Not a production knob; it exists to surface rooting and liveness bugs deterministically, and to show (in the benchmark batteries) that results hold with collection maximally active.
What tuning cannot do: the live floor
Each worker compartment holds its own live intrinsics and globals, ~0.3–0.4 MB per worker that is reachable, not garbage, and therefore untouchable by any GC setting. A 10,000-worker pool has an irreducible floor of a few hundred MB. GC tuning bounds the churn on top of that floor. (Shrinking the floor itself, sharing intrinsics across worker realms, is a tracked architectural optimization, not a tuning knob.)
Measured behavior
Two result sets anchor the claims above (webhook-style workloads, 2 KB payloads; regenerable via the GC-knob sweep script in the cross-runtime benchmark suite).
Scaling and convergence. RSS scales sub-linearly with worker count (~350 MB at 64 workers → ~580 MB at 1024) and, over time, climbs slightly during an adaptive warm-up then converges: the per-wave slope shrinks toward zero, a bounded plateau rather than a runaway. Size a memory-capped container to the converged steady RSS for your worker count.
Against Node workers. The cloud-compute battery (three production-shaped loads, 2000 events each) puts the cost-model difference in numbers: at 2048 workers, Node worker_threads peaked at 22–35× Cruft's RSS and 27–43× its wall time, and Cruft continued to 10,000 workers (~650–730 MB) where the Node column was not run. The incremental cost per additional Cruft worker measured ~50–58 KB. Read this as an isolation-shape benchmark, per-worker work is deliberately small, so it measures fixed per-worker cost (a V8 isolate per worker vs. a realm on a shared heap), not general JavaScript speed. The same ladder run under CRUFT_GC_STRESS=1 stays in the same RSS band with all events delivered.
Diagnosing memory
process.memoryUsage().rss reports real resident memory, so you can measure your own plateau directly. The decision procedure when RSS is higher than you want:
- Is it the live floor? (workers × ~0.35 MB + your working set), GC tuning won't help; reduce live state or worker count.
- Is it churn headroom? Lower
CRUFT_GC_HEADROOMor setCRUFT_GC_TARGET_MBand watch the plateau move toward the floor. - Does stress mode (
CRUFT_GC_STRESS=1) eliminate it? Then it was collectible garbage the trigger hadn't reached yet, a target will bound it. If RSS holds even under stress, it is live: look for a leak in your program's reachability, not in the collector.
Collector soundness
A collector that frees a live object is the most dangerous bug class a runtime can have, so it helps to be concrete about where that risk would live in a tracing collector and how Trash Panda keeps each surface sound.
Start with the collector's own code, because that is where you might expect the raw-pointer hazards. There are almost none. The heap is a Vec of plain-enum slots, not a manual table of raw pointers, so the double-free and dangling-pointer classes are structurally absent from the collector itself (its single unsafe block is a benign call to read process memory on macOS). Object handles are dense slot indices rather than raw pointers, and on the default path a freed slot is not reused, so a stale handle resolves as empty rather than silently aliasing a different object. The shared Tier-2 arena is atomically reference-counted, and its balanced-increment race behavior is tested. The collector also carries a Miri lane that checks the unsafe root and boundary bridges under the borrow checker's runtime model, and CRUFT_GC_STRESS forces a collection at nearly every allocation, driven by a family of liveness tests.
That leaves two places where correctness rests on a manually maintained enumeration being complete, and both are the parts to understand.
Edge enumeration. The collector marks by asking each object to enumerate its own outgoing references, across two traces (a full trace and an incremental one) that must list exactly the same edges. If a special object variant under-reported an edge in either trace, the referenced object could be freed while still reachable. This is the exact shape of two bugs that were found and fixed: the incremental trace once omitted a wrapper edge, and separately an object's property storage was once missed. The enumeration is kept exhaustive so that adding an object variant without giving it a trace arm is a build-time error rather than a silent omission, and the two traces are checked to agree, which is what keeps a new field from re-opening that class.
Job rooting. A queued job (a promise reaction, a timer, an async continuation) is a Rust closure the collector cannot see inside, so any object it captures must be declared in the job's roots list or the collector could free it before the job runs. Every job kind routes its captures through the rooted enqueue path: the promise, async/await, finalization, and thenable jobs, and the two that were once exceptions, the Atomics.waitAsync poll (which re-schedules itself across collections) and the compartment-delivery job. CRUFT_GC_STRESS is the standing net under both surfaces: by collecting at nearly every allocation it turns any missed root from a rare heisenbug into a deterministic failure, and the conformance and worker batteries run under it as a soundness lane.