@zakkster/lite-gc-profiler
v1.9.0
Published
Zero-dependency GC and heap profiler that makes the zero-GC claim falsifiable: three-state verdicts, phases, regions, differential and rep-aware gating, baselines, CLI, and explain mode. precise garbage-collection events in node (perf_hooks), a heap-drop
Maintainers
Readme
@zakkster/lite-gc-profiler
Zero-dependency GC and heap profiler. It exists to make the zero-GC claim falsifiable rather than asserted.
- node → precise: perf_hooks
gcentries (kind + pause duration). - Chrome → heuristic:
performance.memoryheap sampling (alloc rate, drops). - others → long-frame anomaly detection only (no heap API).
The observer receives node-allocated entry lists between frames; the per-frame
methods (sampleHeap, markFrame) allocate nothing.
Single-file ESM, no dependencies, MIT.
The claim, made falsifiable
The zero-GC claim in a package's README should mean something. This library
gives it a testable gate: run your workload, ask if any major GC fired, get
back one of 'pass', 'fail', or 'inconclusive'. On runtimes where the
question cannot be honestly answered, the gate refuses to lie.
Sources
Which signal is live is either detected from the runtime, or overridden
explicitly via new GcProfiler(cap, { source: ... }). cap is the
pause-ring capacity (default 256, rounded up to a power of two, ceiling
2**24 -- the ring costs 16 bytes/slot, so larger values throw rather
than allocate GB-scale buffers):
'gc'-- node (or any V8 runtime exposingperf_hooks gcentries). Precise event kinds and pause durations. Default on node.'heap'-- Chrome. Heuristic based onperformance.memoryheap-drop detection. Default on Chrome. Fast enough for per-frame sampling.'uasm'-- Chrome, opt-in. Accurate memory measurement viaperformance.measureUserAgentSpecificMemory(). Requires cross-origin isolation (COOP+COEP). Async and coarse; not for per-frame use. Never auto-selected -- cross-origin isolation is a deployment choice.'none'-- Firefox, Safari. Frame-anomaly detection only.
Opting into uasm
const gc = new GcProfiler(256, { source: 'uasm' });
// Take a few measurements across the workload:
await gc.sampleUasm();
runHotLoop();
await gc.sampleUasm();
runHotLoop();
await gc.sampleUasm();
// Now summary.uasm.growthRate is bytes/sec across that window,
// and the gate can verify it:
assertNoGc(gc.summary(), { maxAllocRate: 1 * 1024 * 1024 });Throws RangeError on construction if the API is unavailable or the page is
not cross-origin-isolated. summary.uasm is always present, whether or not
you opted in -- shape:
{ supported, bytes, peak, firstSample, samples, growthRate,
granularityBytes, belowGranularity }growthRate is 0 with a single sample; needs two points for a delta.
The granularity floor (v1.9.0)
measureUserAgentSpecificMemory() returns quantized figures, and the
quantum is not contractual -- it varies by browser build, by isolate, and by
what else the page is doing. Treating those readings as exact opened the gate
in both directions:
- A run of identical readings reports
growthRate: 0and used to gate green. But "every reading was identical" is equally consistent with real growth finer than the quantum. That is a pass the channel never earned. - A flat workload sitting on a bucket boundary reports one whole quantum of change between first and last sample. Over a short window that is megabytes per second of growth that never happened, and CI goes red on a workload that allocated nothing.
So the profiler now measures the channel's resolution from the channel:
granularityBytes-- the smallest non-zero step observed between consecutive readings in this window. That is the conservative floor. It isnull, never0, when no step occurred at all:nullmeans not measured, while a floor of zero bytes would claim perfect resolution.belowGranularity--truewhen the window's net displacement is not resolvable above that floor, either because no floor was measured or because the net change sits inside a single quantum.
When belowGranularity is true, maxAllocRate on source: 'uasm' routes to
inconclusive with reason: 'uasm_below_granularity' -- never pass,
never fail. The same rule holds for maxExtraAllocRate on a differential
(a delta is only as resolvable as its worse side) and for gateReps (any
blind rep makes the set unresolvable; resolved reps do not vouch for it).
growthRate itself is left as measured, not rewritten to 0. Silently
replacing an unresolvable rate with a clean-looking zero is the same move as
averaging a missing metric as zero, which the dilution guard exists to refuse.
The flag carries the doubt; the gate acts on the flag.
The fix: sample more times, or across a longer window, until the workload
moves the channel by more than one quantum. If it never does, the honest
reading is that uasm cannot answer your budget question at that resolution
-- gate heap instead, or widen the budget to something the channel can see.
Subpaths
| import | node | browser | intended use |
| --- | :---: | :---: | --- |
| @zakkster/lite-gc-profiler | yes | yes | main API |
| @zakkster/lite-gc-profiler/register | yes | no | preload for auto-attach |
| @zakkster/lite-gc-profiler/test-helpers | yes | no | node:test integration |
| @zakkster/lite-gc-profiler/explain | yes | no | allocator attribution |
Node-only subpaths are additive; the main API stays single-file and browser-safe.
Install
npm install @zakkster/lite-gc-profilerNode: precise GC
import { GcProfiler, assertNoGc } from '@zakkster/lite-gc-profiler';
const gc = new GcProfiler().start();
runHotLoopForAWhile();
// GC entries are delivered asynchronously, so settle before reading.
await gc.settle();
// Strict by default: throws GcBudgetError on fail, GcInconclusiveError if
// the current source cannot verify a rule you set.
assertNoGc(gc.summary());
gc.stop();Phases: warmup vs steady state
gc.phase(name) marks a phase boundary. Everything from the call until the
next phase() call is attributed to that phase. Phases are linear -- no
nesting, no explicit exit. The default state before any phase() call is
unattributed (events count toward global stats but no phase).
const gc = new GcProfiler().start();
gc.phase('warmup');
runWarmupPasses(); // some collections are fine here
gc.phase('steady');
runMeasuredWorkload(); // this window must be clean
await gc.settle();
assertNoGc(gc.summary(), {
phases: {
warmup: { maxMajor: 1 },
steady: { maxMajor: 0, maxMinor: 0 }
}
});
gc.stop();Phases make maxMinor: 0 a usable claim: ambient allocation during warmup
no longer contaminates the steady-state verdict.
Attribution uses each GC event's startTime, not the wall clock at record
time. PerformanceObserver delivers entries asynchronously; the gate
buckets by when the event occurred.
Capacities: 32 unique phases, 1024 boundaries per window. Silent overflow of a gating primitive would defeat the purpose, so both throw.
Scope in v1.1.0: phases attribute GC events only. sampleHeap and
markFrame remain global; per-phase maxAllocRate is inconclusive.
Regions: attributing pauses to code paths
Phases are linear -- warmup, then steady. Regions nest -- you can be inside
render inside frame inside session. GC events attribute to the innermost
open region whose interval contains the event's startTime.
const gc = new GcProfiler().start();
gc.enter('frame');
gc.enter('input');
processInput();
gc.exit();
gc.enter('render');
render();
gc.exit();
gc.exit();
await gc.settle();
assertNoGc(gc.summary(), {
perRegion: {
input: { maxMajor: 0, maxPauseMs: 1 },
render: { maxMajor: 0, maxPauseMs: 4 }
}
});
gc.stop();Rules follow the same three-state verdict semantics. A region referenced in
perRegion but never entered contributes inconclusive. A region-scoped
maxAllocRate is inconclusive in this release -- heap sampling is global,
per-region heap tracking is a future gate.
Capacities: 32 unique region names, 16 nesting depth, 2048 total intervals. Throw on overflow.
Firing-site vs allocator: what regions actually answer
Regions attribute events to where the pause fired, not to who allocated the garbage. V8 collects when allocation debt crosses a threshold; the debtor may be an earlier region.
Concrete case: region A allocates 30 MB, exits cleanly. region B opens,
does modest work, and V8's Mark-Sweep-Compact fires during B because the
threshold from A's allocations was finally crossed. The gate charges B.
That's not blame-shifting; it's a truthful answer to a different question. "Which region incurs pauses" is what users perceive as slowness. "Which region allocated the pressure" is the fix -- and that's what Explain mode answers separately.
Settling: deterministic measurement boundaries
PerformanceObserver delivers GC entries asynchronously, in batches, on the
runtime's schedule. Reading summary() immediately after work completes can
miss entries that fired but were not yet delivered. The v1.0.0 README worked
around this with await new Promise((r) => setTimeout(r, 50)) -- an arbitrary
50 ms guess.
v1.1.0 replaces the guess with gc.settle():
const gc = new GcProfiler().start();
runWorkload();
const { drained, waited } = await gc.settle();
if (!drained) {
// Downgrade any verdict to inconclusive -- the observer queue never quieted,
// so summary() may be missing entries.
}
assertNoGc(gc.summary());
gc.stop();Semantics: settle() polls a batch counter each macrotask; after N consecutive
quiet ticks it declares drained. On timeout it resolves with drained: false.
Options:
quietTicks(default 2) -- consecutive quiet ticks required.maxWaitMs(default 200) -- hard timeout.
settle() is a no-op on source: 'heap' and source: 'none', and on a
profiler that was never .start()ed. It resolves immediately with
{ drained: true, waited: 0 }.
The observer callback gained one integer increment (a batch counter) and nothing else; hot-path allocation is unchanged from v1.0.0.
Browser: heap + frames
import { GcProfiler, assertNoGc } from '@zakkster/lite-gc-profiler';
const gc = new GcProfiler().start();
function frame(t) {
gc.sampleHeap(t); // performance.memory in Chrome; no-op elsewhere
gc.markFrame(dt); // frame duration for anomaly detection
render();
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
// Later, gate on allocation rate:
assertNoGc(gc.summary(), { maxAllocRate: 2 * 1024 * 1024 });Gate
The gate returns a three-state verdict: pass, fail, or inconclusive.
An inconclusive verdict means the current source cannot verify one or more
of the rules you set -- it is not the same as pass, and by default it throws.
Falsifiability requires that a gate never be silently green when it could not
actually check what it was asked to check.
import { checkNoGc, assertNoGc } from '@zakkster/lite-gc-profiler';
const report = checkNoGc(gc.summary(), {
maxMajor: 0, // no full-heap collections (default)
maxPauseMs: 4, // no single pause over 4 ms
maxAllocRate: 2 * 1024 * 1024 // <= 2 MB/s allocation (heap path)
});
// report -> {
// kind: 'gc',
// verdict: 'pass' | 'fail' | 'inconclusive',
// ok: boolean, // === verdict === 'pass'
// violations: [...],
// checked: { maxMajor: true, maxPauseMs: true, maxAllocRate: false },
// checkedByPhase: {},
// checkedByRegion: {},
// source: 'gc' | 'heap' | 'none'
// }
assertNoGc(gc.summary()); // strict
assertNoGc(gc.summary(), rules, { allowInconclusive: true }); // permissiveRules: maxMajor (default 0), maxMinor, maxPauseMs, maxTotalMs, maxAllocRate.
Verifiability matrix
Which rules each source can actually verify:
| rule | gc (node) | heap (Chrome) | uasm (Chrome, opt-in) | none (Firefox/Safari) |
| --------------- | :---------: | :-------------: | :---------------------: | :---------------------: |
| maxMajor | yes | no | no | no |
| maxMinor | yes | no | no | no |
| maxPauseMs | yes | no | no | no |
| maxTotalMs | yes | no | no | no |
| maxAllocRate | needs heap | needs heap | needs uasm | no |
"needs heap" means the rule is verifiable iff summary.heap.samples >= 2.
"needs uasm" means the rule is verifiable iff summary.uasm.samples >= 2
(computing a growth rate requires at least two measurements) and, since
v1.9.0, iff those samples actually resolved growth above the channel's own
quantum -- see the granularity floor above. Feed samples
with gc.sampleHeap(now, process.memoryUsage().heapUsed) in node, let the
browser path sample performance.memory automatically for heap, or call
await gc.sampleUasm() a few times per window for uasm.
The matrix is exported as VERDICT_MATRIX for tools that want to render it
or filter rules to the current source.
Errors
GcBudgetError-- thrown fromassertNoGconverdict: 'fail'.GcInconclusiveError-- thrown fromassertNoGconverdict: 'inconclusive'unless{ allowInconclusive: true }. Message names the unverifiable rules.
Both carry .report with the full report.
Per-phase rules
Rules accept an optional phases map alongside global rules. Each phase's
rules are evaluated against summary.phases[name].gc:
checkNoGc(gc.summary(), {
maxMajor: 0, // global rule
phases: {
warmup: { maxMajor: 1 }, // relaxed for warmup
steady: { maxMajor: 0, maxMinor: 0 } // strict for steady
}
});A phase referenced in rules but never declared via profiler.phase(name) is
inconclusive. A phase declared but with no events verifies as pass.
The report grows a checkedByPhase map alongside checked.
Snapshot keys. As of v1.5.2 summary.phases and summary.byRegion
define their keys with Object.defineProperty, so a phase or region named
__proto__ lands as a real own key instead of silently setting the
snapshot's prototype and disappearing from Object.keys and
JSON.stringify. The prototype itself is untouched: reads, iteration,
spreads, serialization, deepStrictEqual and hasOwnProperty all behave
exactly as before.
Differential: comparing against a control
Absolute gating fails when the harness itself allocates: any GC caused by
the harness gets charged to the candidate, and a real regression drowns in
the noise. compareGc(control, candidate, rules) gates on the delta
(candidate - control), not absolute numbers.
import { compareGc, assertCompare } from '@zakkster/lite-gc-profiler';
async function measure(fn) {
const gc = new GcProfiler().start();
fn();
await gc.settle();
const s = gc.summary();
gc.stop();
return s;
}
const control = await measure(pooledNoop); // harness noise baseline
const candidate = await measure(myCode); // candidate under test
assertCompare(control, candidate, {
maxExtraMajor: 0, // no additional majors
maxExtraPauseMs: 1, // no additional pause > 1ms
maxExtraAllocRate: 1024 * 1024 // at most 1 MB/s extra
});Rules: maxExtraMajor (default 0), maxExtraMinor, maxExtraPauseMs,
maxExtraTotalMs, maxExtraAllocRate.
Source mismatch is inconclusive. If control and candidate come from
different sources (e.g. one node, one browser), the differential is
meaningless and the verdict is inconclusive with reason:
'source_mismatch'.
Interleaving contract: control and candidate should come from interleaved
reps to absorb machine-mood variance. Combine with gateReps (below) to
enforce it.
Rep-aware gating: variance and policy
A single run says too little. Many runs say more, but only if you gate on
them coherently. aggregateGc(summaries) collects reps into a stats block
per metric; gateReps(summaries, rules, options?) applies rules under
per-rule policies.
import { aggregateGc, gateReps, assertReps } from '@zakkster/lite-gc-profiler';
const reps = [];
for (let i = 0; i < 10; i++) {
const gc = new GcProfiler().start();
runMyCode();
await gc.settle();
reps.push(gc.summary());
gc.stop();
}
assertReps(reps, {
maxMajor: 0, // strict: no rep may have a major
maxPauseMs: 4 // strict: best rep proves 4ms is achievable
});Policies:
'all-clean'-- every rep must satisfy (aggregate uses max). For kind rules (majors, minors), a single dirty rep falsifies the claim.'best-clean'-- at least one rep must satisfy (aggregate uses min). For pauses and rates, the best rep proves the clean state is achievable; the rest is machine noise.'median'-- median across reps must satisfy.'quorum-N'-- at least N reps must individually satisfy.
Defaults:
| rule | default policy |
| -------------- | ---------------- |
| maxMajor | all-clean |
| maxMinor | all-clean |
| maxPauseMs | best-clean |
| maxTotalMs | best-clean |
| maxAllocRate | best-clean |
Override per rule via options.policy:
assertReps(reps, { maxMajor: 0, maxPauseMs: 4 }, {
policy: {
maxMajor: 'quorum-9',
maxPauseMs: 'median'
}
});Mixed sources across reps -> inconclusive with reason: 'mixed_sources'.
Per-op measurement: hot-path primitives
measureOps, assertOps, and compareOps are the shape you want when the
thing being gated is a single operation -- a signal notification, a keyed-
selector call, a hot-loop tick -- not a whole test file. Same verdict
discipline as the whole-window gate; per-op scale.
import { measureOps, assertOps, compareOps } from '@zakkster/lite-gc-profiler';
// Measure: how many bytes per notify?
const result = measureOps((i) => signal.set(i), { ops: 10_000, warmup: 500 });
// result.bytesPerOp, result.opsPerSec, result.summary (full profiler summary)fn(i) gets the iteration index. measureOps is the sync ops
primitive; use measureOpsAsync (v1.5.0) when your workload awaits.
Internal phase() boundaries quarantine warmup allocations from
steady-phase gating; bytesPerOp is derived from the steady heap delta
alone.
Why result.summary.phases.steady.gc reads zero on sync measureOps
Node's PerformanceObserver -- the mechanism this profiler uses to hear
GC events -- delivers callbacks on event-loop turns. A synchronous
measureOps loop never yields, so the observer's delivery queue never
gets a turn to fire before stop(). The events happened; the observer
saw nothing.
This is why the ops lane exposes only bytesPerOp as a rule -- memory
readings do not require an observer turn -- and no event-based rules
(maxMajorsPerKOp, maxMinorsPerKOp, maxPauseMsPerOp are absent
from the sync ops checkOps gate for exactly this reason). The rules
would be unenforceable on their own primitive.
The zeros in result.summary.phases.steady.gc on a sync measureOps
run are honest -- "the observer saw nothing," not "the workload was
clean." If you need GC-event counts under real churn, use measureOpsAsync
(each await yields the event loop back to the observer) or
measureFrames (the scheduler yields between frames).
Gating a per-op limit
// Throws GcBudgetError if steady-phase bytes-per-notify exceeds 0.
assertOps(
(i) => signal.set(i),
{ maxBytesPerOp: 0 },
{ ops: 10_000, warmup: 500 }
);Four rule names:
maxBytesPerOp-- heap growth divided by opsmaxMajorsPerKOp-- major collections per 1000 opsmaxMinorsPerKOp-- minor collections per 1000 opsmaxPauseMsPerOp-- total pause milliseconds per op
Verifiability follows the same matrix as whole-window rules -- the memory
rules need a memory channel (needsHeap/needsUasm); the event-kind rules
need source: 'gc' (node). All four appear in the exported VERDICT_MATRIX
with all four source columns.
Throughput is intentionally reported in the result but not gated. Benchmark
harnesses have opinions on opsPerSec; this package stays in the "prove
zero-GC per op" lane. If you want to fail CI on throughput regressions,
use compareOps with maxExtra*PerOp limits below.
Noise floor: choosing ops for maxBytesPerOp: 0
bytesPerOp on node has a small residual noise floor from V8's own
loop-bookkeeping (feedback vectors, tier-up allocations, incremental
marking) -- roughly 500-1200 bytes per loop regardless of ops. The
per-op floor scales as noise / ops:
| ops | Approx floor | Suggested maxBytesPerOp |
| --- | --- | --- |
| 10K+ | < 0.15 B/op | 0 (reliable) |
| 1K+ | < 1.5 B/op | 2 (recommended) |
| <500 | > 3 B/op | not recommended for 0 |
V8's residual bookkeeping is orthogonal to the sampling infrastructure
and can't be eliminated in userland. For strict zero-alloc claims,
prefer ops >= 10_000, or use stabilize: true (see the Cold CI
section above) to reduce sensitivity to transient allocation and V8
timing.
Comparing two implementations
// Primitive form: two results, one report.
const control = measureOps(oldImpl, { ops: 10_000, warmup: 500 });
const candidate = measureOps(newImpl, { ops: 10_000, warmup: 500 });
const report = compareOps(control, candidate, { maxExtraBytesPerOp: 0 });
// verdict: 'pass' | 'fail' | 'inconclusive'Convenience form runs measureOps twice for you with matched opts:
compareOps(oldImpl, newImpl, { maxExtraBytesPerOp: 0 }, { ops: 10_000, warmup: 500 });Source mismatch between control and candidate yields inconclusive with
reason: 'source_mismatch' -- comparing node measurements against Chrome
measurements is never meaningful, and the gate says so instead of pretending.
assertCompareOps throws in the same way as assertOps -- one call for
CI, no result-handling boilerplate.
Cold CI: use stabilize: true
Warm-workload measurement (what measureOps does by default) is the right
answer when the code under test has already run in the process -- typical
of bench harnesses, integration suites, and interactive dev loops. In a
cold CI shard -- where the first call to assertCompareOps is
literally the first time V8 has seen these paths -- two effects can
collapse a legitimate leak signal to zero:
- JIT tier-up allocation churn inflates the control's
bytesPerOp, narrowing the delta between control and candidate below the gate threshold. - A one-off major GC mid steady-loop compacts
heapUsedbelow the start-boundary sample, making the reported delta non-positive. SincebytesPerOpclamps negative to zero, the retained candidate's leak disappears from the report.
Neither is a bug in the primitive -- both are "bytesPerOp may be 0 if GC
ran," the library's documented contract. But the contract has a
uncomfortable gap for cold-CI callers, which is precisely where
assertCompareOps is designed to be called.
stabilize: true closes the gap. It forces a full GC at each
steady-phase boundary, so bytesPerOp reflects the surviving-allocation
delta (retention) rather than transient allocation:
assertCompareOps(
control, candidate,
{ maxExtraBytesPerOp: 20 },
{ ops: 1000, warmup: 100, stabilize: true }
);Requires node --expose-gc; throws RangeError at measurement time
otherwise with actionable guidance ("run: node --expose-gc ..."). The
forced-GC events are attributed to a separate stabilize phase in the
summary so they don't inflate steady-phase counters.
When to use it:
- Cold-CI shards running per-op gates as their first workload.
- Any zero-allocation claim where you care about retention ("my signal notification retains zero bytes") rather than transient churn.
assertCompareOpsin package tests where the answer should be deterministic regardless of test-run order.
When to skip it:
- Warmed workloads where you already have deterministic behavior.
- Runtimes without
--expose-gc(browser measurements, sandboxed CI). - Gates that mix
maxBytesPerOpwithmaxMajorsPerKOp-- stabilize's forced fulls arrive asynchronously via perf_hooks and typically aftermeasureOpsreturns, so thestabilize.gc.majorsummary counter is unreliable. Usestabilize:truefor retention gating,stabilize:falsefor GC-event-count gating; picking either separately is honest and correct.
Per-frame measurement: measureFrames and the render-loop lane
The ops lane answers "what does one call cost?" The frame lane answers "how does this behave inside a render loop?" Different question, different noise floor, different failure modes.
import { measureFrames, assertFrames } from '@zakkster/lite-gc-profiler';
// Async -- frames are inherently async, driven by a scheduler.
const result = await measureFrames((i) => {
updateParticles(i);
drawScene();
}, { frames: 300, warmup: 60 });
// result shape (schema: 'lite-gc-frames/1')
// frames: 300, warmupFrames: 60
// elapsedMs, fps
// bytesPerFrame // retention slope, null on source='none'
// majorsPerKFrame, minorsPerKFrame, maxPauseMsPerFrame
// droppedFrames // frames whose work-time > frameBudgetMs
// frameTimes: { p50, p95, p99, max }
// asyncResidual // bytes heap grew past settle() -- smoke detector
// source, summaryThe scheduler
measureFrames drives a scheduler through warmup + frames ticks, one
call to your function per tick. Three modes via opts.scheduler:
'auto'(default) — usesrequestAnimationFrameif the runtime has one, otherwise falls back to a self-correctingsetTimeoutpolyfill that targetsframeBudgetMs(default 16.67ms) with drift compensation.'raf'— forces raf. Throws aRangeErrorat setup if unavailable — no silent fallback, so the intent is honest.'polyfill'— forces the setTimeout pacer.- A function
(cb) => handle— the escape hatch. Deterministic schedulers in tests (e.g.(cb) => setTimeout(cb, 0)) run 300 frames in ~150ms instead of ~5s.
bytesPerFrame: retention slope, not two-point delta
The ops lane uses a two-point heap delta (start vs settled end). For 300+
sample points across a real render loop, that's the wrong shape — V8
runs minor GCs mid-window, dropping heapUsed sharply between samples.
A two-point delta would collapse under those drops.
The frame lane periodically samples the heap (~32 samples across steady),
detects the drops (a sample less than 0.8× the previous marks a GC),
and fits a least-squares slope through the post-drop anchor points. That
tracks retention accumulating across GC boundaries, robust to V8's
mid-steady collections. A workload that only churns transient garbage
converges to bytesPerFrame ≈ 0; a real leak accumulates as a positive
slope through the post-GC floor.
The five per-frame rules
await assertFrames(renderFrame,
{
maxBytesPerFrame: 50, // needsHeap / needsUasm / no on source=none
maxMajorsPerKFrame: 1, // requires source=gc
maxMinorsPerKFrame: 10, // requires source=gc
maxPauseMsPerFrame: 4, // requires source=gc
maxDroppedFrames: 3 // source-agnostic
},
{ frames: 300, warmup: 60 }
);Four of these mirror the per-op rules' verifiability. The fifth,
maxDroppedFrames, is the first source-agnostic gate in
VERDICT_MATRIX — work-time is measured directly from
performance.now(), no memory channel needed. Users on a runtime with
source='none' (headless without any memory instrumentation) can still
gate frame drops. That's the shape check that the matrix design
generalizes cleanly.
asyncResidual: the smoke detector
Every result includes asyncResidual: bytes the heap grew after
gc.settle() returned. Non-zero means work spawned inside your frame
outlived the measurement window — a fire-and-forget promise chain, an
unawaited microtask, a background timer. Not a gate rule in v1.4.0, just
a free signal you can log or assert against directly.
Comparing frames
Same delta pattern as compareOps:
await assertCompareFrames(oldRenderer, newRenderer,
{ maxExtraBytesPerFrame: 20, maxExtraDroppedFrames: 0 },
{ frames: 300, warmup: 60 }
);Source mismatch (control on gc, candidate on none) yields an
inconclusive verdict, same as everywhere else in the library.
Attribution honesty: interleaved async is not yet solved
If your frame function spawns fire-and-forget promises whose allocations
are attributed by V8's async-context propagation to whichever phase is
current when the perf_hooks callback delivers the GC event, attribution
can drift. For a cooperative frame function (fully awaits its own work),
attribution is accurate. asyncResidual gives the escape signal for the
uncooperative case. Full interleaved-async attribution — separating
frame-N's spawned work from frame-N+K's synchronous work — is a
concurrency-lane concern for a future release; doing it honestly
needs workers.
Serialized async ops: measureOpsAsync
The ops lane answers "what does one call cost?" for synchronous work.
measureOpsAsync answers the same question for async work: signal
setters that batch to microtasks, effects committed on a scheduler,
Preact-Signals reactions, Svelte 5 rune ticks.
import { measureOpsAsync, assertOpsAsync } from '@zakkster/lite-gc-profiler';
const result = await measureOpsAsync(async (i) => {
signal.set(i);
await scheduler.flush();
}, { ops: 10_000, warmup: 500 });
// Same rule vocabulary as measureOps -- no new gate types to learn.
await assertOpsAsync(async (i) => signal.set(i),
{ maxBytesPerOp: 5 },
{ ops: 10_000, warmup: 500 }
);Serialization contract
measureOpsAsync awaits fn(i) fully before starting fn(i+1). Ops
do not overlap under this primitive. What fn does inside its own
promise -- fire-and-forget microtasks, background timers, queueMicrotask
chains -- is fn's problem, surfaced via asyncResidual in the result
(same smoke-detector semantic as the frame lane). Full interleaved-async
attribution across ops is a v1.6.0+ concurrency-lane concern.
Stabilize on by default
Following the v1.4.0 frame-lane lesson: measureOpsAsync is already
async, already calls settle(), and the marginal cost of two forced
GCs at steady boundaries is trivial compared to the honesty gain.
stabilize: true is therefore the default whenever globalThis.gc is
available (node --expose-gc). On that path, bytesPerOp is the
compacted-live-set delta between steady boundaries -- clean workloads
read ~0, real leaks read their true retention rate, and the reading is
stable cold-vs-warm.
Explicit opt-out: stabilize: false uses a raw two-point delta and
flags the result bytesPerOpStable: false. stabilize: true without
--expose-gc throws RangeError at setup -- no silent fallback.
The result shape
{
schema: 'lite-gc-ops-async/1',
ops, warmupOps,
elapsedMs, opsPerSec,
bytesPerOp, // live-set delta when stabilized, else raw two-point
bytesPerOpStable, // true iff the stabilized path ran
majorsPerKOp, minorsPerKOp, maxPauseMsPerOp,
asyncResidual, // bytes heap grew past settle
source, summary
}Same rule vocabulary as checkOps: maxBytesPerOp, maxMajorsPerKOp,
maxMinorsPerKOp, maxPauseMsPerOp. Delta rules for compareOpsAsync:
maxExtraBytesPerOp, maxExtraMajorsPerKOp, maxExtraMinorsPerKOp,
maxExtraPauseMsPerOp.
Multi-context aggregation: gating across worker heaps
Every measurement lane above measures one shared heap in one context.
That's what the "overlapping measurements throw" hardening in v1.5.1
enforces -- all lanes share one heap. But a real workload distributed
across N Node worker_threads, or N browser Web Workers, is N heaps, N GC
observers, N PerformanceObservers. There is no single shared heap to
observe.
aggregateWorkerReports takes an array of per-context measurement
results and produces a unified aggregate that can be gated against the
same rule vocabulary as single-context measureOps. Pure aggregation --
no spawning, no messaging, no perturbation. You bring the workers, the
aggregator handles the semantic.
import {
aggregateWorkerReports, checkAggregateReport, assertAggregateReport
} from '@zakkster/lite-gc-profiler';Node CI gates: worker_threads
import { Worker } from 'node:worker_threads';
import { fileURLToPath } from 'node:url';
import { assertAggregateReport } from '@zakkster/lite-gc-profiler';
// worker.mjs -- runs measureOps on this context's heap and posts the result.
//
// import { measureOps } from '@zakkster/lite-gc-profiler';
// import { parentPort } from 'node:worker_threads';
// const result = measureOps(hotPath, { ops: 10_000, warmup: 500, stabilize: true });
// parentPort.postMessage(result);
const workerUrl = new URL('./worker.mjs', import.meta.url);
function runOne() {
return new Promise((res, rej) => {
const w = new Worker(workerUrl); // inherits --expose-gc from parent
w.once('message', (m) => { w.terminate(); res(m); });
w.once('error', rej);
});
}
const reports = await Promise.all([runOne(), runOne(), runOne(), runOne()]);
assertAggregateReport(reports, { maxBytesPerOp: 5 });Node worker_threads inherit the parent's --expose-gc. Do not pass it
via execArgv -- Node rejects that with ERR_WORKER_INVALID_EXEC_ARGV,
because --expose-gc can only be set at top-level process start.
Browser 60fps: @zakkster/lite-worker
For the browser side, @zakkster/lite-worker gives you a zero-GC
per-frame channel that pairs cleanly with the frame-lane primitive.
Each worker runs measureFrames on its own heap, posts the result
back over the typed channel (ctx.post/.call), the main thread
collects and aggregates. See the lite-worker README for the transport
details.
The aggregation semantics
The aggregator encodes conservative decisions:
bytesPerOp:(total retained bytes across all contexts) / (total ops across all contexts). Weighted by ops, so a 1-op context with a huge rate cannot swamp a 1M-op context with a tiny rate. If any context reportsnullor non-finite, the aggregate isnull.bytesPerOpStable: logical AND across contexts. One context falling back to the raw two-point delta degrades the aggregate flag. A gate cannot be more trustworthy than its least-trustworthy source.majorsPerKOp,minorsPerKOp: ops-weighted rate. Same shape as single-context.maxPauseMsPerOp: MAX across contexts. The worst pause anywhere in the system is the pause the aggregate reports.source: unanimous or'mixed'. A mixed-source aggregate is gatedinconclusivewithreason: 'source_mismatch'-- deltas across mixed sources are not comparable.
Result shape
{
schema: 'lite-gc-ops-multi/1',
kind: 'ops-multi',
contexts: 4,
aggregate: {
source: 'gc',
totalOps: 40000,
bytesPerOp: 3.2,
bytesPerOpStable: true,
majorsPerKOp: 0.1,
minorsPerKOp: 2.4,
maxPauseMsPerOp: 3.8
},
perContext: [ /* the input reports, defensive copy */ ]
}The v1.5.1 gate-fail-closed discipline extends to checkAggregateReport:
unknown rule keys throw, non-finite thresholds throw, non-finite aggregate
metrics route to inconclusive (never pass).
Frames variant: aggregateFrameReports (v1.8.0)
The same shape applies to the render-loop lane. Each context runs
measureFrames, ships its result back, the aggregator produces a
multi-frames report.
import {
aggregateFrameReports, checkAggregateFramesReport, assertAggregateFramesReport
} from '@zakkster/lite-gc-profiler';
const reports = await Promise.all(workers.map(runOne));
assertAggregateFramesReport(reports, {
maxBytesPerFrame: 512,
maxDroppedFrames: 5
});Semantics mirror the ops variant, with three frames-specific decisions:
droppedFrames: SUM across contexts (not averaged). Three contexts each dropping one frame is three dropped frames system-wide.asyncResidual: SUM across contexts. Fire-and-forget growth accumulates.frameTimes: deliberately dropped from the aggregate. Percentiles are not compositional -- a system-wide p95 cannot be reconstructed from per-context summary p95s.perContext[i]preserves the per-context distributions for manual inspection.
The dilution guard from v1.7.1 applies from day one: a missing or
non-finite rate metric (majorsPerKFrame, minorsPerKFrame,
maxPauseMsPerFrame, droppedFrames) on ANY context marks the
aggregate metric null, routing to inconclusive at gate time.
Silently averaging a missing metric as zero would let an unmeasurable
context read the whole system cleaner than reality.
Baseline lock: guarding against silent regressions
CI ergonomics: capture a known-good aggregate once, commit it as JSON, gate every future run against it.
import { aggregateGc, createBaseline, checkAgainstBaseline } from '@zakkster/lite-gc-profiler';
import { readFileSync, writeFileSync } from 'node:fs';
// Once, on a green build:
const baseline = createBaseline(aggregateGc(reps));
writeFileSync('gc-baseline.json', JSON.stringify(baseline, null, 2));
// Every subsequent build:
const baseline = JSON.parse(readFileSync('gc-baseline.json', 'utf8'));
const current = aggregateGc(reps);
const report = checkAgainstBaseline(current, baseline);
if (report.verdict === 'fail') { /* regression */ }
if (report.verdict === 'inconclusive') { /* baseline unusable here */ }createBaseline does not touch the filesystem; it returns a JSON-able
object. Users serialize and commit as they see fit.
Regression semantics: for each metric, current.median > baseline.max
is a regression. Rationale: allowing current to be as bad as the baseline's
worst absorbs run-to-run noise on the capture side; a current whose typical
value exceeds even the worst observed baseline is a real regression.
A baseline that cannot verify anything is inconclusive, not pass
(v1.5.2). A comparison counts only when both current.median and
baseline.max are finite, so a metric whose baseline value is NaN,
null (what JSON.stringify writes for NaN), or a hand-edited string
reports checked: false rather than silently comparing false against
everything. If no metric survives -- a truncated baseline file, missing
gc/heap groups, schema drift, an empty aggregate -- the verdict is
inconclusive with reason: 'no_comparable_metrics'. Regenerate the
baseline rather than reaching for allowInconclusive.
Fingerprint check. createBaseline captures a fingerprint of the
environment (node, v8, platform, arch, cpu). Comparing against a baseline
whose fingerprint differs from the current environment returns
inconclusive with reason: 'fingerprint_mismatch'.
Override the fingerprint check explicitly if needed:
checkAgainstBaseline(current, baseline, { acceptFingerprintMismatch: true });
// The report body carries fingerprintMismatchAccepted: true as audit trail.CLI: lite-gc-gate
Zero-touch gating for any node script:
lite-gc-gate run <script> [options]| flag | meaning |
| --- | --- |
| --reps N | Run N times and gate on the aggregate |
| --config path | Load rules and policy from JSON |
| --format fmt | console | json | markdown | github (default console) |
| --json path | Also write the JSON envelope to this path |
| --baseline path | Check against a baseline JSON file |
| --update-baseline | Write current aggregate as new baseline |
| --accept-fingerprint-mismatch | Allow baseline comparison across fingerprints |
| --allow-inconclusive | Exit 2 instead of 1 on inconclusive |
Exit codes: 0 pass, 1 fail, 2 inconclusive, 3 infrastructure error.
The target script does not need to know about the profiler. The CLI spawns
node with the ./register preload, which starts a GcProfiler at load,
settles on beforeExit, and writes the summary JSON to a temp path the CLI
then reads.
Config file shape:
{
"rules": { "maxMajor": 0, "maxPauseMs": 4 },
"policy": { "maxMajor": "quorum-9" }
}Example: gate under 10 reps with GitHub Actions output:
lite-gc-gate run bench/hot.mjs --reps 10 --config gc-gate.json --format githubExample: capture a baseline once, gate against it thereafter:
# Green build, once:
lite-gc-gate run bench/hot.mjs --reps 20 --baseline gc-baseline.json --update-baseline
# Every subsequent build:
lite-gc-gate run bench/hot.mjs --reps 20 --baseline gc-baseline.json --format githubprocess.exit() handling (v1.3.0+). If the target script calls
process.exit() before beforeExit can settle, the register preload's
sync exit handler writes a partial report (schema: 'lite-gc-partial/1').
The CLI reads it, downgrades verdict to inconclusive with
reason: 'partial_report', and emits exit code 2 (inconclusive) rather
than 3 (infrastructure error). CI can distinguish "target hard-exited,
measurement is truncated" from "harness genuinely broken." Reports carry
a partial field with per-rep exit codes for debugging.
Test integration: node:test
The ./test-helpers subpath exports withGcGate, a wrapper that turns the
start/settle/assert dance into a one-liner. On failure, the formatted
report is attached to the test's diagnostic output so CI logs show what the
gate saw next to the test name.
import { test } from 'node:test';
import { withGcGate } from '@zakkster/lite-gc-profiler/test-helpers';
test('zero-alloc claim', async (t) => {
await withGcGate(t, { maxMajor: 0 }, async (gc) => {
runMyCode();
});
});With phases:
test('warmup then steady', async (t) => {
await withGcGate(t, {
phases: {
warmup: { maxMajor: 1 },
steady: { maxMajor: 0, maxMinor: 0 }
}
}, async (gc) => {
gc.phase('warmup');
warmTheCache();
gc.phase('steady');
runMyCode();
});
});measureGc is the quieter form: returns the report instead of asserting.
Useful when the test wants to inspect the verdict rather than fail.
A canonical test/99-gc-gate.mjs template ships under templates/GcGate.mjs.
Every @zakkster/lite-* package that wants the Zero-GC badge copies this
verbatim, adjusting only the workload body and package import.
Formatters
Four pure functions render any report into a target format. All accept the
report shape returned by checkNoGc, compareGc, gateReps, or
checkAgainstBaseline; dispatch is on the kind field.
formatConsole(report)-- human-readable, aligned columns, ASCII-only. Suitable for stderr and CI job logs.formatJson(report)-- stable versioned envelope with schema tag and generation timestamp. Round-trippable.formatMarkdown(report)-- GitHub-flavored markdown, PR-comment ready.formatGithubAnnotations(report)-- GitHub Actions workflow annotations (::error::/::warning::/::notice::).
The CLI's --format flag picks one of these; nothing in the library forces
you to use the CLI though -- import the formatters directly in any tool.
Evidence lane: making a failed gate readable
A verdict: 'fail' object is not a CI log line. explainReport,
explainDiff, and gateBadge turn any gate report into something a
human can act on and a README can display.
All three sit under the same ./explain subpath. They are pure
formatters -- read a report, emit a string. No measurement, no
observer, no perturbation. Safe to run in a signal handler, an exit
hook, or a browser without contaminating the very thing that just
failed.
import { assertOps } from '@zakkster/lite-gc-profiler';
import { explainReport, gateBadge } from '@zakkster/lite-gc-profiler/explain';
try {
await assertOps(signalSet, { maxBytesPerOp: 5 },
{ ops: 10_000, warmup: 500, stabilize: true });
} catch (err) {
console.error(explainReport(err.report, { colour: true }));
fs.writeFileSync('gc-badge.json',
gateBadge(err.report, { format: 'shields-json' }));
throw err;
}explainReport output shape
For a fail:
gc-gate: FAIL -- ops
Violations (1):
maxBytesPerOp
actual: 47.20
limit: 5 (+42.20; +844.00% over limit)
means: bytes per op
Run:
ops: 10000
warmup: 500
source: gc
stabilized: yesFor a compare, a Comparison block with control + candidate absolute readings appears above the Run footer so the deltas are read against their side-by-side context, not in isolation.
For an inconclusive, a Cannot verify: block names the specific rules
that could not be checked and the source they ran against. pass gets
a compact "N rules verified" summary.
Hints fire only when the report carries concrete evidence for them
(asyncResidual > 0, bytesPerFrameStable: false,
bytesPerOpStable: false, reason: 'source_mismatch'). No speculative
advice.
gateBadge for README ornaments
Three formats:
'text'--gc gate: pass/gc gate: fail (2)/gc gate: inconclusive'shields-json'-- the shields.io endpoint schema ({ schemaVersion, label, message, color }) that reads over HTTPS from a static file, driving a live badge in a README'svg'-- a self-contained ~1 KB shields-style SVG string
Colours: brightgreen / red / yellow for pass / fail / inconclusive.
explainDiff for cross-baseline comparisons
For the case where a caller ran two separate check* calls -- e.g.
against distinct baselines from different runs -- and wants a
compare-style narrative without going through compare*. Kind mismatch
between the two reports is surfaced in the header, not thrown, in case
the diff is deliberately cross-lane (an ops report vs a frames report
for a summary slide).
Explain mode: allocator attribution
When a gate fails, regions tell you where the pause fired. Explain mode
tells you which allocation stacks caused the pressure. It uses V8's
sampling heap profiler via node:inspector.
STRICT OPT-IN. Never active during a gated run. The sampler perturbs the very thing measurement is trying to capture; running it inside a gate would corrupt every zero-major claim in the same window.
import { startExplainSampling, formatExplainConsole } from '@zakkster/lite-gc-profiler/explain';
const handle = startExplainSampling({ intervalBytes: 512 * 1024, topN: 10 });
await handle.started;
runTheCodeYouWantToExplain();
const result = await handle.stop();
process.stdout.write(formatExplainConsole(result) + '\n');Output shape:
Top allocation stacks (interval=524288 bytes):
allocateBucket 256.0 KB file:///project/src/pool.js:42
parseChunk 128.0 KB file:///project/src/parse.js:17
copyOnWrite 64.0 KB file:///project/src/cow.js:81
...The smaller intervalBytes, the more detail -- and the more perturbation.
Default is 512 KB.
Node-only. Browsers do not expose the inspector protocol.
A note on cost: self-noise, measured
The observer receives node-allocated entry lists between GC events, and the profiler's presence in a process is not zero-cost. We measured what it costs.
Setup: profiler started, primed for JIT warmup, global.gc() forced,
settle() awaited, reset() called. Then a 500 ms zero-allocation noop loop.
Measured on node.js under --expose-gc. Reproducible via
node --expose-gc --test test/07-self-noise.mjs.
Observed self-noise (range across dev hardware, five-run measurements):
| metric | measured | asserted ceiling | | ------------- | --------------- | ------------------ | | major GCs | 0 | 0 (hard) | | minor GCs | 1-13 | 30 | | longest pause | 0.3-0.7 ms | 5 ms | | total pause | 0.4-1.5 ms | (not asserted) | | p99 pause | 0.3-0.7 ms | (not asserted) | | heap growth | 144 B - 770 KB | 4 MB (sentinel) | | settle wait | 2.5-4 ms | (not asserted) |
Zero majors is the strict invariant: a single self-induced major would poison every user "zero-major" claim. The minor and pause ceilings are regression sentinels -- generous enough to absorb the wide per-hardware variance in scavenge frequency (a fast dev box may see 1-2 minors per 500 ms noop; a slower CPU or busy system may see 10-15).
Heap growth is a regression sentinel only. It's noisy: most of the 144 B - 770 KB
range is V8 runtime state (JIT code cache, timer queue, ambient promise/
microtask allocation), not the profiler. The range is honest -- run-to-run
variance on real machines, published as measured rather than smoothed. A
tight per-profiler heap contribution number requires a differential against
a control run without the profiler; that measurement is available via
compareGc.
Backwards compatibility with v1.0.0
Existing v1.0.0 code keeps working: report.ok still exists (as an alias
for verdict === 'pass'), report.violations is unchanged, and
assertNoGc(summary) still throws only GcBudgetError in the cases where
v1.0.0 threw it.
New in v1.1.0: assertNoGc may also throw GcInconclusiveError. If your
v1.0.0 code ran only on node with the default { maxMajor: 0 } rule, this
cannot happen -- source: 'gc' verifies maxMajor. If your code ran a
browser gate with maxMajor: 0, that path was silently green in v1.0.0 and
now correctly fails; pass { allowInconclusive: true } to restore the old
behavior, or scope rules to the source via VERDICT_MATRIX.
File layout notes
The main source file was renamed from index.js to Gc.js in v1.1.0 to
match the ecosystem PascalCase convention. A back-compat shim is shipped
as index.js (and index.d.ts) that re-exports everything from Gc.js,
so any code that hard-coded ./index.js in a relative path -- including
tests in the pre-v1.1.0 repo -- keeps working without modification.
Other renames (all relative paths inside the package, none affecting
package.json subpath names): register.mjs -> Register.mjs,
test-helpers.js -> TestHelpers.js, explain.js -> Explain.js,
bin/lite-gc-gate.mjs -> bin/LiteGcGate.mjs,
templates/gc-gate.mjs -> templates/GcGate.mjs.
Do not move templates/GcGate.mjs into test/. It's a template with
a <PACKAGE_NAME> placeholder that only compiles after being copied and
customized. If it gets picked up by the test runner from test/, it will
fail to import.
Test file naming: new tests in v1.1.0 follow the NN-name.test.mjs
convention so node --test discovers them automatically alongside the
v1.0.0 test files. Torture tests live at test/torture/*.test.mjs and
share test/torture/harness.mjs (a helper file, not a test).
Testing
npm test # 702 tests
npm run coverage # the same suite, under the coverage law702 tests, all passing on this hardware.
The coverage law
npm run coverage runs the full suite with --experimental-test-coverage
and fails the process below any of three floors:
| metric | floor | | --------- | :---: | | lines | 95% | | functions | 95% | | branches | 85% |
It is wired into prepublishOnly, so a release cannot go out under the
floors.
Two exclusions matter. test/** is excluded because test code is
near-100% executed by definition -- letting it into the aggregate means the
suite grades itself and the shipped-file number silently drifts upward.
**/tmp/** is excluded for the same reason: several torture scenarios write
throwaway fixture modules to a temp directory and execute them completely.
The floors above are therefore shipped files only, which is a materially
different (and lower) number than an all-files run reports.
Floors move only by the ratchet rule -- measured minus one, and only when exceeded by at least 2 points across three consecutive full runs on both machines, on the shipped-file basis. Coverage is a guardrail here, not a target to chase.
Torture
Torture tests (278 scenarios
across axes A-W) enforce that adversarial inputs never silently pass, that
real signal in noise always fails, that clean signal under hostile
conditions always passes, and that self-consistency invariants hold across
the API. Later axes add resource and concurrency safety (E-I), then
hostile identifiers, poisoned samples, capacity cliffs, baseline
integrity, prototype-pollution inputs and lifecycle (J-S), then
observation-window integrity, the capacity ceiling, the retention floor
and deep teardown (T-W). See TORTURE.md for the per-slot breakdown.
Every torture scenario started as a successful attack on the library. The
unifying theme of the serious findings has been FAIL-OPEN behaviour: a
budget gate that reports 'pass' on input it never verified is worse than
no gate, because CI stays green while the invariant rots.
License
MIT. Copyright (c) Zahary Shinikchiev.
