Memory & GC
On this page 11
zig-js has two allocation modes, chosen per Context. Which one you pick
changes lifetimes, reclamation, and what your host code must do to keep values
alive.
Mode 1 — arena (default)
A default Context owns an arena allocator. Values and objects are
bump-allocated and freed wholesale when the context is destroyed. Nothing is
reclaimed during the context's life.
This is the right mode for short-lived contexts: it is the fastest allocation
path, and teardown is a single release. It is the wrong mode for a long-running
realm that churns objects, because nothing comes back until destroy().
Mode 2 — precise tracing GC
const ctx = try js.Context.createWith(gpa, .{ .enable_gc = true });
Heap cells route through the precise collector (the sibling zig-gc package,
bound in src/gc.zig).
Design record: P7 GC design.
The collector is precise, not conservative — it knows the engine's exact
types and traces only real references. That is not a purity preference: a JS
heap is full of f64s that alias pointers, and precise reachability is
required to clear weak references and fire finalizers correctly.
collectGarbage() runs a quiescent collection: it reclaims unreachable cells,
clears WeakRef targets, prunes WeakMap/WeakSet weak keys, queues
FinalizationRegistry records, and drains their cleanup callbacks as host
cleanup jobs at checkpoints.
A no-GIL threaded context implies this path, and there the collector also runs abort-safe mid-script while other threads execute.
Roots — what keeps a value alive
Per live Context, the tracer enumerates the global object, the environment
chain, the root shape and TDZ marker, the microtask queue, the exception slot,
async waiters, completed thread records, property-mode waitAsync tickets,
active module caches (each cached module roots its environment and namespace
object), and registered active interpreter roots at checkpoints — current
environment and bindings, this, return/exception/new.target, active native
function, with objects, symbols, and import.meta.
SharedArrayBuffer retains are deliberately not roots: SAB storage is
refcounted process-wide, and the GC only finalizes the wrapper's retain.
Host handles
The collector cannot see your Zig or C variables. A value held only by host code must be registered:
const handle = try ctx.protectValue(v);
defer _ = ctx.unprotectValue(handle);
C embedders use JSValueProtect / JSValueUnprotect, which register the boxed
cell in a per-context counted handle table that the tracer walks.
Write barriers and funnelled mutation
Concurrent and parallel collection use a Dijkstra-style insertion barrier at the
reference-storing sites. This is affordable precisely because the engine funnels
mutation: Object.setOwn / setSlot, element appends, environment binding
writes, and shape transitions. Keep new reference stores inside that funnel —
a fresh ad-hoc store site is a missing barrier.
The AST is immutable and arena-owned, outside the GC heap. Functions reference it by pointer without the collector tracing it.
Generational collection
A nursery with an age-based promotion policy is implemented and benchmarked. The current published evidence (report) pairs moving and non-moving age-one and age-three parents. Across the accepted single-mutator rows, moving age three is 0.63–1.01× its exact non-moving parent and 0.84–1.02× moving age one. The recorded moving age-three rows copied 480.42 MiB with zero movement failures. The shared three-mutator rows recorded a 110.56 ms maximum moving pause and 12 bounded retries across 14 samples; every sample completed within the enforced two-retry ceiling.
Reproduce with:
zig build gc-generation-benchmark
zig build gc-generation-benchmark -Dgc-generation-benchmark-quick=true
The moving nursery relocates multi-age survivors at exact quiescent, VM, optimizer, and shared no-GIL safepoints. Opaque peers retain the moving request and fall back conservatively after a bounded allocation window; shared exact rendezvous retries are bounded rather than making unrewritable native frames movable.
Compaction and relocation
Moving collection exists, but under an explicit contract (GC relocation contract):
Context.compactGarbagemoves on a quiescent precise-GC realm;- the automatic quiescent full-GC policy moves when slab pressure leaves at least 512 KiB of reclaimable fragmented backing;
Context.requestGarbageCompactionis consumed at the AArch64 numeric tier's declared precise checkpoint;- published code, tier metadata, bytecode chunks, and native-frame storage do not move;
- running JS threads, generic/native-host checkpoints, conservative stack scans, and in-flight concurrent/parallel collections fail closed.
C and Objective-C embedders get the same boundaries through
ZJSGlobalContextCreateGarbageCollected, ZJSContextRequestGarbageCompaction,
and ZJSContextCompactGarbage.
The checked-in relocation inventory covers all nine production cell kinds and 27 pointer surfaces, each naming its representation, source anchor, and required relocation or pinning rule. The drift gate is cheap and runs in ordinary CI:
zig build gc-relocation-inventory-check
If you add a cell kind or a pointer surface, you add an inventory entry and a
rewriter in the same change — the verifier derives the live CellKind enum
from src/gc.zig and requires exact ordered coverage.
Published compaction evidence: 90.8% less retained fragmented backing (8.81 → 0.81 MiB) with a 0.99 ms median pause and unchanged post-action throughput (report). Automatic shared/mid-script compaction evidence remains an open release gate.
Heap budgets
const ctx = try js.Context.createWith(gpa, .{ .heap_limit_bytes = 64 << 20 });
The cap covers Context-owned allocations: arena chunks, GC cell slabs and side storage, thread records, and other allocations routed through the context allocator. Arena-backed caps fail closed. GC-backed caps can collect and retry at safe allocation-recovery points.
Inspect at runtime:
_ = ctx.heapBudgetStats(); // limit / used / peak
_ = ctx.runtimeHeapAccounting();
_ = ctx.parallelGcStats(); // no-GIL collector telemetry
Weak references and finalization
WeakRef and FinalizationRegistry are only meaningful under enable_gc —
they need precise reachability. Cleanup callbacks are queued as host cleanup jobs
and drained at checkpoints, never inline during a collection. The harness global
$drainFinalizationCleanup steps that queue deterministically in tests.
Native stack safety
Where native frames may hold references, the engine uses conservative stack
scanning (src/stack_scan.zig)
plus a root handshake (src/root_handshake.zig)
to publish roots across threads.
For the native tiers there is a hard rule until precise native stack maps exist: no GC pointer may be live only in a machine register across a safepoint. See Execution tiers.
Profiling memory work
zig build gc-profile # allocation and Context lifecycle cost
zig build gc-profile -Dgc-profile-case='nursery'
zig build midgc-profile # mid-script parallel-GC convergence and pauses
zig build gc-compaction-benchmark
zig build gc-generation-benchmark
Profiles locate cost; they are not correctness gates and not publication evidence. Publishing a memory or pause claim follows the same rules as any performance claim — see Benchmarks.