Memory Footprint
How memory-efficient are these libraries at runtime? Three things to know before reading the charts.
What we measure
For each iteration of every scenario, the harness:
- Snapshots heap and RSS via
Deno.memoryUsage()/process.memoryUsage()before the scoped scenario run. - Runs the scenario, then snapshots again — that gives
heapUsedDeltaandrssDelta. - Forces a major GC (
globalThis.gc()via--expose-gcon Node/Deno;Bun.gc(true)on Bun) and snapshots heap a third time asheapUsedAfterGc. - Peak memory during the iteration is collected by the scenario itself: each scenario calls
ctx.markPeak()at its structural high-water moment (the leaf of recursion, the moment after all events have been dispatched but before teardown). The harness combines those marks with the before/after snapshots and stores the max asheapUsedPeak/rssPeak. Because the marks are placed deterministically, peak capture works equally well for sub-millisecond recursion and longer-running events scenarios — no sampling timer to fight the event loop.
The forced-GC snapshot and peak snapshots are taken outside the timed window, so they don't contaminate latency.
Two heap metrics, two questions
- Post-GC retained heap (
heapUsedAfterGc) answers "what does the library hold onto when idle?" The forced-GC snapshot strips out unreachable garbage so it only counts live state.heapUsedAfter - heapUsedBefore(without forced GC) was contaminated by whether a natural major GC happened to fire mid-iteration — we've seen+22 MBp50 next to-28 MBp50 on otherwise comparable scenarios — so we don't use the un-GC'd delta for comparison. - Peak heap during iteration (
heapUsedPeak) answers "what's the working-set high-water mark while the scenario is running?" Since scenarios mark their own peak viactx.markPeak(), this captures the moment when listeners + in-flight events + temporary allocations are all alive — before teardown frees most of them. The gap between peak and post-GC retained tells you how much of a library's footprint is transient vs. durable.
RSS varies wildly across runtimes
The Trimmed-mean RSS Δ chart looks completely different depending on the runtime, and most of the difference is allocator behavior, not library behavior:
- Node / Deno (V8): V8 hoards memory and grows arenas in 128 KB chunks, so per-iteration RSS deltas are quantized — any single iteration is either 0 or a 128 KB jump. We use a trimmed mean across iterations because the median is always 0 and the plain average is dominated by cold-start arena commits on the first measured iteration.
- Bun (mimalloc): often negative. Bun's allocator decommits pages back to the OS via
madvise(MADV_FREE)afterscoped()teardown, so RSS literally drops between iterations. - Bun heap accounting is sparse:
process.memoryUsage().heapUseddoesn't track the JSC heap meaningfully, so on Bun read RSS instead of heap; on Node/Deno read heap instead of RSS.
The RSS chart is included for completeness; the post-GC heap chart is the one you actually want for cross-library comparison.
Median Post-GC Retained Heap (relative)
Per-library, faceted by scenario type. Bars show how much more memory each library retains compared to the most efficient library in the same scenario type — the lightest library sits at 0 and others show their cost above it. The absolute floor is around 37-41 MB across the board (V8 + module/runtime + JIT code + scenario state); the differences between libraries (0.1-3 MB) are the actual signal but get visually drowned out when plotted as absolute values.
The bar height shows each library's heap above the most efficient one in the same scenario type — the lightest library sits at 0 by definition. The number above each bar is its absolute median post-GC heap in MB, so you can read the baseline value without hunting through the data.
Median Peak Heap During Iteration (relative)
Median high-water-mark of heap usage during each iteration, again shown above the lightest library in the same scenario type. Schema v5 added a ScenarioCtx.markPeak() hook that scenarios call at their structural peak (the leaf of recursion, the moment after all events are dispatched but before teardown). Peaks are deterministic snapshots, not sampled — the harness combines explicit marks with the before/after snapshots and keeps the max.
This answers a different question than the post-GC chart above: what's the working-set high-water mark while the scenario is running, vs. what's left over after a major GC. Events scenarios should show much higher peaks than retained heap because their listener chain is alive during dispatch and freed at teardown; short recursion scenarios should look similar to their retained heap because there's barely any time between peak and end.
Peak ≥ post-GC retained for any given scenario. The gap between them is the per-iteration "working" allocation — temporary objects allocated during the scenario and reclaimed by the time the forced GC runs.
Trimmed-mean RSS Δ per Iteration
Process-wide RSS change from start to end of each iteration, with a 10% trimmed mean across all measured iterations: sort, drop the highest and lowest, average the rest. We can't use the median because V8's allocator grows arenas in 128 KB chunks — per-iteration deltas are quantized to either 0 or +128 KB and the median collapses to 0. We can't use a plain average because cold-start arena commits on the first measured iteration can be tens of MB and dominate the result for the rest of the run. The trimmed mean is the middle ground: discards the cold-start outlier and any one-off GC reclaim, keeps the steady-state signal.
Note Bun runs negative on this chart for many scenarios because mimalloc decommits pages back to the OS after scoped() teardown.
Caveats
- Retained, not peak. We don't sample heap during the scenario, only at the boundaries. Peak working-set during execution can be substantially higher than what shows here.
- Median across 10 iterations is robust to single-iteration GC events but can hide slow growth across a run.
heapUsedAfter - heapUsedBefore(the un-GC'd delta) is available in the underlying data if you want to dig in. effect-v4is beta (4.0.0-beta.64); these numbers will move as the beta evolves.effection-inline.recursiondoesn't run on Effection 3.6.0 / 3.6.1 because the inline plugin requires v4. Bars for that combination are absent by design.