Loom

Type to search across the guides, specification and decision records.

↑↓ navigate ↵ open esc close Pagefind

Guides

Benchmarks

Run all benches: pnpm bench. Per package: pnpm --filter @loom/<pkg> bench.

Two kinds of bench live here. Most are vitest bench micro-benchmarks measuring the latency of one operation in a serial loop. The throughput harness (tools/bench-throughput) is different: it measures sustained ops/sec against a real Postgres with N writes in flight, which vitest bench cannot express. See Durable write throughput.

These baselines are the no-regression gate for the cooperative-scheduling work (see packages/scheduler and the plan). The render fast-path (traverseBlueprint) is deliberately left untouched; treat a >~2% regression on it as a failure.

Numbers below are indicative (Apple Silicon, Node 20, vitest bench). Re-capture on your CI host to set the authoritative baseline; compare relative movement, not absolute hz, across machines.

@loom/plugin-crypto — field-level encryption overhead (K10-R7 / ADR 0022 A4)

plugins/crypto/src/append.bench.ts — the marked-vs-unmarked-vs-unwired append benchmark ADR 0022 amendment A4 promised, proving the G2 bound (no overhead when unwired) and the honest opt-in cost. Three shapes on the same payload:

ShapeRelativeWhat it shows
unwired (plain store.append)1× (baseline)no wrapper at all
wrapped, empty sensitive map~1.1×one per-event-type map lookup, no crypto (G2)
wrapped, one field encrypted~22×the actual per-marked-field AES cost (paid only for marked fields)

The empty-map wrapper being ~1.1× (a single lookup) — not the ~22× of the encrypted path — is the provable “byte-identical when unmarked” claim. Each store is built once, outside the measured function: constructing the wrapper costs about 1.3 µs more, which an app pays once at boot and which earlier versions of this benchmark timed on every iteration. Against a real database these ratios are far smaller, because the write dominates. Run: pnpm --filter @loom/plugin-crypto bench.

traverseBlueprint — render fast path (regression gate)

Pure synchronous walk, no scheduler involvement. Scales ~linearly with node count.

Nodesmean (ms)hz
100.0005~1,970,000
1000.0048~207,000
1,0000.0451~22,000
10,0000.4668~2,140

Takeaway: even a 10k-node tree traverses in ~0.47ms — well inside a frame budget. This is the empirical basis for not adding a Fiber-style time-slicer to the render path.

@loom/scheduler — overhead guardrails

BenchResult vs baseline
schedule (Infinity) vs direct await~1.0–1.04× (negligible)
parallelLimit unbounded vs Promise.all~1.7× (sub-µs absolute; dwarfed by real I/O)

Takeaway: the unbounded scheduler adds no meaningful latency; gating cost is only paid when a finite concurrency cap is explicitly configured.

projection storm — pre-coalescing baseline (Phase 4 target)

One handleEvent per streamed token; subscriber fires once per event today.

Burstmean (ms)subscriber calls
1000.0131100
1,0000.12141,000

Phase 4 coalescing target: keep wall time flat or better while collapsing subscriber calls from O(events) → O(microtask flushes).

DS&A hot-path optimizations (PR #63)

Algorithmic wins on paths that are hit per-tick or per-append. Each is a strict complexity improvement with identical outputs — no behavior change, so they only widen the no-regression margin. See ADR 0015.

PathBeforeAfter
nextOccurrence cron matching (workflow-runtime/src/cron.ts)minute-by-minute scanfield-jumping — advance directly to the next matching field
Reasoning model routing (reasoning/src/router.ts)linear scan of routes per callprebuilt index — O(1) tag lookup
Crypto field walks (plugins/crypto/src/crypto.ts)repeated full-object traversalsingle-pass walk over marked paths
deploy-sim censusre-derived per flowhoisted/linearized

These sit off the render fast-path and within the >~2% regression gate above.

Advisory pre-warming — cold-start hidden (PR #64)

Off-log, opt-in warming of cold resources (P10 advisory plane; latency-only, never a fold input). Measured on the K7 isolated-executor boundary — the largest cold start:

K7 isolated tool — first-call latencyms
cold (lazy fork + module require on first execute)~54.2
warm (child pre-spawned at boot via warm() / spec.warm)~2.0
cold start hidden by pre-warm~52

Pre-warming boosts latency, not throughput, and only pays off at genuine I/O / cold-start boundaries (K7 child, LLM connection, scheduled fires) — so it is scoped there and nowhere else. See ADR 0027.

Batched content resolution — frozen-snapshot serving (ADR 0062)

ContentStore.getMany + batch-scoped two-phase resolution in ContentResolvingEventStore (collect distinct refs across the read batch → one round-trip → pure substitute). Measured resolving 50 externalized refs through a 2ms-per-round-trip backend (plugins/content/src/content.bench.ts):

resolve 50 refs @ 2ms backend latencymean ms
pre-ADR-0062 shape (serialized per-ref awaits)~114
coalesced parallel gets (fallback, no getMany)~2.7
getMany (one batched round-trip)~2.6

~44× over the old serialized shape; comfortably inside the TS-10 interactive budget (p95 ≤ 150ms for ≤ 50 refs, asserted in batchedResolution.test.ts). The batched-vs-coalesced gap is small against an in-memory backend (latency does not scale with concurrency there); on real network stores with connection caps the single round-trip is the durable win. CachingContentStore layers cache-forever on top (content-addressed ⇒ immutable), with delete-evicts-first so erasure is never resurrected.

Durable write throughput (tools/bench-throughput)

The first measured answer to “how many requests per second can Loom do”. Run: LOOM_TEST_POSTGRES_URL=... pnpm --filter @loom/bench-throughput bench (skips cleanly without a database). Full method and knobs: tools/bench-throughput/README.md.

Captured on Apple Silicon, Node 24, loopback PostgreSQL 17.2, 256B payloads. Loopback flatters the store — no network hop, shared page cache — so treat these as an upper bound for a single primary and re-capture on the host you intend to quote.

Append saturates at ~22K events/sec, and past c≈32 concurrency hurts

concurrencyevents/secp50 msp99 msmax ms
15,4730.170.350.46
822,3640.340.631.31
3222,3561.401.953.42
12817,8025.6937.82150.34

Throughput plateaus by c=8; adding in-flight writes past that buys latency, not capacity.

Correction: the c=128 row was first read as a regression. A wider sweep (below) shows it is noise — raw append throughput is flat from c=8 to c=1024.

Concurrency above 128: throughput flat, latency linear

concevents/secp50 msp99 ms
820,2710.321.7
3217,0551.517.2
12819,1946.0213.7
25621,38911.7413.6
51215,63924.01109.0
102420,16948.1073.1

Textbook saturated-resource behaviour: throughput pinned at ~20K events/sec across a 128× concurrency range, while p50 latency grows linearly (0.32 → 48 ms). Past c≈8 every extra in-flight write is queueing, not work.

…but execution-layer concurrency scales to ~512, because executions are mostly idle

Raw-append concurrency and execution concurrency are different things, and conflating them is easy. An execution with a slow effect spends almost all its time not writing, so concurrency there buys real throughput. 1-step flow, 100 ms effect:

concexec/secevents/secLittle’s Lawefficiencyp50 msp99 ms
322911,16232091%105116
1281,1734,6911,28092%104115
5124,02516,1005,12079%107147
10243,70414,81810,24036%195506
20484,95519,82120,48024%381441

Near-linear to c=512 at ~90% of Little’s Law, with p50 latency still at the effect’s own 100 ms. Past that it plateaus in a noisy 3.7–5K exec/sec band — ~16–20K events/sec, i.e. exactly the write ceiling from the table above — and the extra concurrency shows up as latency (p50 105 → 381 ms), not throughput.

Practical reading: on this hardware a 1-step, 100 ms-effect workload saturates at ~4,000–5,000 exec/sec around c=512. Earlier guidance extrapolated ~5,500 exec/sec at c≈550 from the write ceiling alone; the measured plateau is ~25% below that.

Batching: the SQL shape is worth up to 8×, but most of that is NOT reachable from the journal

Batched append is not a shipped path, and it is not an open design question either: ADR 0065 already froze the sketch (appendBatch(envelopes[]), drained inside ExecutionJournal’s serial tail) and deferred it behind a three-part evidence gate. What follows is evidence about the SQL shape, measured with the same fence guard and one pg_notify per batch tip:

batchevents/secvs shippedp95 ms
115,8210.7×16.3
469,4563.1×7.8
16183,1418.2×19.8
64143,8576.4×143.3

Read the 8.2× carefully — it is an upper bound on the statement, not on the design. Every batch here holds 16 events of one execution, because that is what a single fence guard permits. But ExecutionJournal is per-execution: a 1-step flow emits 4 envelopes total, a 3-step flow 8. A journal-side batcher can therefore never assemble a 16-event batch for these flows — batch=4 (3.1×) is the hard ceiling for a 1-step flow, and even that requires all four envelopes to be enqueued before the serial tail drains, which only happens when the store is already saturated. With a realistic (slow, I/O-bound) effect, STEP_STARTED and STEP_COMPLETED are seconds apart and the queue drains between them, so a journal-side batch collects one envelope and wins nothing. This is precisely the “a 3-events-per-run workload cannot win” case ADR 0065’s gate 2 calls out.

The 8.2× is only reachable by coalescing across executions — many concurrent journals’ appends merged into one multi-row INSERT — which is not ADR 0065’s sketch, would belong in a store decorator rather than the kernel, and is unmeasured: a cross-execution batch needs a per-row fence check (a join against fences) instead of the single guard measured here, and that cost is unknown.

This bench does not open ADR 0065’s gate. Gate 1 wants event-write throughput to be the bottleneck on a real workload; this harness uses a no-op effect specifically to remove effect latency, which is the opposite. Gate 3 (transient architecture insufficient) is not addressed at all. Gate 2 is met only for the SQL shape, and contradicted for the journal.

pg_notify costs ~37% of append throughput, and fan-out is linear in subscribers

variant (c=128)events/secvs no-notify
append WITHOUT the pg_notify CTE30,8841.00×
append (shipped)19,4530.63×
append (shipped) + 16 idle LISTEN conns9,8860.32×

Sixteen idle listeners — subscribed to nothing in particular, doing no work — cut writer throughput in half. Measured delivery was exactly 16 notifications per appended event: the channel has no server-side interest filter, so every node receives every event and discards what it does not want. This is an O(events × nodes) broadcast, and it degrades the writer, not just the readers.

Correction (2026-09-28): interest-filtered delivery (bucketed LISTEN, ADR 0074 D4) shipped and fixed exactly the part of this measurement that is client-side — a node no longer receives events it has no interest in. It did not fix the writer-side cost measured above: that turned out to be per LISTENing backend and channel-independent, so a listener LISTENing on a channel nobody publishes to costs the writer almost as much as one receiving every event. CoalescingEventStore, not further interest-filtering, is the mitigation for that cost. See the ADR 0074 amendment and scaling.md §3.1 for the re-measured numbers; this baseline is kept verbatim above as the historical measurement that motivated bucketing in the first place.

End-to-end durable executions

No-op effect, so this is kernel + store cost with nothing else in it. Events per execution (2*steps + 2) is asserted against rows actually written, so the events/sec column cannot drift.

flowexec/secevents/secp50 msp99 ms
1 step (4 events)5,69522,78111.015.2
3 steps (8 events)2,48819,90121.370.7

Both land at ~20–23K events/sec — the same ceiling as section A, which is the point: the event log is the bottleneck, not the interpreter. Execution throughput is just that ceiling divided by events per execution.

The transient path is not in the same regime

scenariocalls/secevents written
runtime.invokeTransient (no-op effect)1,112,6000

ADR 0066 D2/R1 holds under load: zero envelopes. ~195× the durable 1-step rate, because it writes nothing. On this path the kernel is not the constraint — the effect’s own work is.

Cross-execution coalescing: viable, but not the same-execution headline

Section B batches 16 events of ONE execution under a single fence guard — a shape the per-execution ExecutionJournal can never fill for short flows. The only shape that can fill a batch is a store-level coalescer merging appends across many concurrent executions, and that cannot use one guard: rows belong to different executions, so each needs its own fence check (a join against fences), and each execution in the batch needs its own pg_notify. This is the cost the guide previously recorded as unknown.

Two full runs, c=128, one row per distinct execution:

batchrun 1 (events/sec)run 2 (events/sec)
4, fenced56,61824,365
16, fenced141,14180,209
64, fenced115,514182,974

Absolute numbers here are noisy — treat the ratio, not the value. At a matched batch size of 64 the fenced cross-execution shape ran at 0.55× and 0.65× of section B’s same-execution batch across the two runs.

So: a cross-execution coalescer is viable — roughly 4–8× over the shipped per-event append — but it does not reach section B’s headline. Budget for about 0.6× of it once per-row fencing and per-execution notifies are paid.

What could not be resolved: whether the fences join itself is the expensive part. The fenced-vs-unfenced delta flips sign between runs (unfenced faster at b=64 in run 1, slower in run 2), so it is inside run-to-run variance. Anyone wanting that number needs repeated runs on a quieter host than a laptop.

None of this opens ADR 0065’s gate — it sizes a design, it does not demonstrate a real workload bottlenecked on event writes.

Multi-node fan-out: 4 real subscriber nodes cost more than 16 idle listeners

Section C approximates fan-out with idle LISTEN connections in-process. A real node does more: it holds a PostgresEventStore, and onNotify issues a SELECT per notification to fetch the row. Measured with genuine forked subscriber processes, c=128:

subscriber nodesrun 1run 2
018,09621,175
212,52112,742
48,27510,223

Writer throughput falls 2.19× / 2.07× from 0 to 4 nodes. Delivery was exactly 1 notification per node per appended event in both runs — the broadcast, confirmed end-to-end across processes rather than inferred.

Put against section C: 16 idle listeners cost ~1.97×; just 4 real subscriber nodes cost ~2.1×. The difference is onNotify’s per-event SELECT. Fan-out is therefore roughly 4× worse per node than the idle-listener approximation suggested, and the cost lands on the writer.

Correction (2026-09-28): this measurement’s framing — that interest-filtered delivery would be the fix, ahead of everything else in scaling.md — did not hold up. Bucketing shipped (ADR 0074 D4) and removed the onNotify per-event SELECT difference this section measures for nodes outside a listener’s interest, but the writer-side fall-off measured here (2.07×–2.19× at 4 nodes) is caused by the number of LISTENing backends, not by what they are interested in, and re-measuring after bucketing shipped found the same order of loss (1.68×–2.63× across repeat runs). The actual highest-leverage change for this cost is CoalescingEventStore (measured 1.21×–1.54× recovery), not further interest-filtering. See the ADR 0074 amendment and scaling.md §3.1.

Coalescing, as shipped (ADR 0074)

The measurements above size counterfactuals in SQL. This is the implementation: CoalescingEventStore over PostgresEventStore.appendBatch, including everything the real path pays — the per-row fence JOIN, per-envelope pg_notify, and the all-or-nothing guard. It is section A2 of the harness (coalescedAppend), driven exactly like section A — one serial writer per execution, ops counted per event — so the rows compare line for line. c=64, 6,400 events, median of 3 runs:

configurationevents/secvs shipped
per-event append21,6141.0×
CoalescingEventStore (default maxBatch: 16)32,1901.5×
CoalescingEventStore (maxBatch: 64)43,1262.0×

These numbers replace an earlier table that nothing in the repo could reproduce. It reported 2.3× and 4.3× for the same two configurations, and the harness contained only the raw-SQL counterfactuals of sections B and G — no scenario ever instantiated the decorator. The per-event baseline was reproduced closely (18,190 then, 21,614 now, same laptop class), so the gap is in the coalesced rows rather than in the machine. What is reproducible is what this table now says, and re-running it is one command:

LOOM_TEST_POSTGRES_URL=postgres://… LOOM_BENCH_CONCURRENCIES=8,64 LOOM_BENCH_OPS=100 \
  LOOM_BENCH_BATCHES=1,16,64 pnpm --filter @loom/bench-throughput bench

Note the default is the conservative one. The earlier “knee at 16, regression at 64” came from the same-execution statement shape in section B; the cross-execution shape behaves differently and 64 is faster here. Its tail is where the trade shows: p99 rises from 3.95 ms at 16 to a 14.2 ms max at 64 in the same run, which is why the default stays at 16 — raise it deliberately, with your own p99 in hand.

The honest bottom line on batching is therefore 1.5–2.0× measured end to end on the shipped decorator — not section B’s 8.2×, which was always an upper bound on a statement rather than on a design, and not the 2.3–4.3× this page previously claimed.

The decorators without a database: what they cost, and what they buy

Everything above needs a live Postgres, so none of it runs in CI and none of it can be re-checked on a laptop. packages/event-runtime/src/scaleDecorators.bench.ts is the part that can: it prices ONE round-trip explicitly (ROUND_TRIP_US, spun rather than slept, so scheduler granularity stays out of a measurement about call counts) and appends 512 envelopes across 512 distinct executions concurrently — the real shape, N journals each holding one pending envelope.

Run it with npx vitest bench --run src/scaleDecorators.bench.ts from packages/event-runtime. At a 20µs round-trip, on the same laptop as the rest of this file:

configurationops/secvs undecorated
per-event append (no decorator)96.81.0×
CoalescingEventStore (default maxBatch: 16)1,43714.8×
CoalescingEventStore (maxBatch: 64)4,95051×

Those ratios track the round-trips saved (512 → 32 → 8) rather than a property of any backend: the point of the parameter is that a reader who thinks their own round-trip is dearer or cheaper can move it and re-run. What the Postgres table above adds — and this one cannot — is everything a real backend also pays: the fence JOIN, pg_notify per envelope, the all-or-nothing guard. That is why 51× on a modelled round-trip is 2.0× on a database.

The second half of the bench is the counterweight, and it is the reason these decorators are inert unless explicitly composed:

over a FREE (in-memory) backendops/seccost
InMemoryAsyncEventStore (no decorator)8,074—
CoalescingEventStore over in-memory5,4311.49× slower
ShardedEventStore over 4 in-memory shards3,5502.27× slower

There are no round-trips to save against a store in the same process, so coalescing there is pure overhead, and sharding buys parallelism a single-process bench cannot show and this deliberately does not claim. Wire them where a round-trip is real; leave them out where it is not.

Read path: loadState no longer reads the whole log

foldState used to read the ENTIRE log before checking for a snapshot, then throw that result away — so snapshots bounded the fold’s CPU but not its I/O. Now it loads the snapshot first and reads only a bounded tail (plus a one-row existence anchor).

Measured with snapshotEvery: 2, c=16, median of 3 runs. p50 is the reliable signal here; the ops/sec column carries tail-outlier noise on the long-log case.

log lengthbefore (p50)after (p50)before (reads/sec)after (reads/sec)
4 events0.72 ms0.71 ms17,69017,936
102 events4.76 ms2.93 ms3,0774,297

~1.6× faster at p50 on a 102-event log, and no regression on short ones — as expected, since the un-snapshotted path still issues the same number of queries.

…and a settle-time checkpoint, because snapshotEvery alone does not bound reads

The read fix above only got falloff from ~5.75× to ~4.17×, not to ~1×. Probing why turned up a second defect: on a 102-event execution with snapshotEvery: 2, exactly one snapshot was stored, at sequence 2 — every read folded a 99-event tail.

maybeSnapshot can only fire while the execution is still committing, and it skips while a save is in flight. A fast execution commits its entire log inside that first async save, so it checkpoints once, near the start, and nothing after completion ever revisits it. suspend already checkpoints at its tip; completion did not.

Adding the symmetric settle-time checkpoint — gated on snapshotEvery being set and the tail since the last checkpoint having reached it, so short executions and the default unset config are untouched:

102-event log, p50reads/sec
before both fixes4.76 ms · 3,077
bounded foldState read only2.93 ms · 4,297
+ settle checkpoint1.22 ms · 10,939

3.9× cumulative at p50, and the snapshot now lands at the tip (tail folded on read: 99 → 0). Cost is exactly one extra snapshot write per completed execution whose tail reached the cadence — probed at 2 snapshots per execution rather than 1, and zero when snapshotEvery is unset.

Residual falloff is ~1.6×, and is not log length: a 50-step execution’s snapshot state is simply bigger to fetch and parse than a 1-step one (50 frames vs 1). That part is inherent.

What did NOT work, measured: changing the cadence trigger from an exact multiple to an interval since the last checkpoint — the obvious reading of the defect. It is inert in both regimes. A fast execution has no commits left by the time the save resolves; a slow one lets the exact-multiple trigger keep up anyway (299 vs 300 checkpoints over the same workload). It was dropped rather than shipped as unmeasured complexity.

Profiling for ADR 0065 gate 1: where the write/effect crossover actually is

ADR 0065 gate 1 asks for a real workload where event-write throughput — not effect latency — is the measured bottleneck, and insists on a profile rather than intuition. ProfilingEventStore (@loom/event-runtime) and effectTimingMiddleware (@loom/middleware) are the instruments for answering that on your traffic; both are decorators/middleware, so they are inert until wired.

Sweeping synthetic effect latency locates the boundary. c=32, Postgres, aggregate work-time attribution:

effect latencyevents/execexec/seceffect mswrite mswrite shareverdict
0 ms44,158224,82099.6%write-bound
1 ms45,5853463,60591.2%write-bound
1 ms221,0016,09320,39277.0%write-bound
5 ms44,9023,0082,17642.0%effect-bound
25 ms2212041,43614,08325.4%effect-bound
100 ms2231161,83713,7677.8%effect-bound

The crossover sits at roughly 2–3 ms of effect latency. Below it the write path dominates; above it, it does not, and batching the write path cannot help however large the raw event rate looks.

That number is the useful output, and it points away from the gate for typical Loom workloads. An LLM call, an agent step, or a remote tool invocation is 100 ms to seconds — 30× to 1000× above the crossover, landing at 8–25% write share. For those, effect latency is the constraint and always will be.

Caveats on the method: the share is of aggregate work time (writes and effects both run concurrently, so each column can exceed wall time), which is a first-order attribution rather than a critical-path analysis. ExecutionJournal also queues appends on a serial tail, so a sharper signal for a borderline workload is time blocked at drain() barriers — not yet instrumented.

Takeaway for capacity planning

  • Durable executions: ~5.7K/sec per runtime + single Postgres primary for a 1-step flow. 100K/sec of that shape is 18× this capacity, and because every write lands on the same log, that multiplier falls on Postgres — worker scale-out (PlacementDispatcher, worker-remote, cloudrun) does not help.
  • The best-evidenced headroom is interest-filtered notify delivery — measured, no SPI change, no gate against it. Batching is gated (ADR 0065) and its journal-side form is worth far less than the 8.2× headline; see the batching section above.
  • Stateless/transient: not a scaling problem. One process already exceeds 100K/sec of kernel overhead; size for the effect, not the runtime.
  • Before quoting the 5.7K anywhere: it is a no-op-effect ceiling. With a realistic I/O-bound effect the write path is nowhere near the bottleneck — sustaining 5.7K exec/sec of 2-second LLM steps would need ~11K concurrent executions, and concurrency, memory, and the provider’s rate limit bind first. Treat these numbers as where the floor is, not as a throughput target any real workload approaches.

See docs/scaling.md for what to do with all of this.

Heartbeat lease overhead (tools/bench-throughput, ADR 0130 D11 / Q8 gate)

ADR 0130 D11 (Q8) ships eager leasing (every driver holds a heartbeat lease from before its first append) only if the lease overhead is at most 10% on the no-op throughput ceiling and none at I/O-bound durations, else it falls back to lazy. This measures exactly that: the same 1-step flow, heartbeat OFF vs ON (defaults: ttl 30 s, renew every 10 s, real timers), OFF and ON interleaved per repeat (alternating which goes first) and reduced to medians.

LOOM_BENCH_BACKENDS=memory,sqlite,postgres LOOM_BENCH_CONCS=16,64,256 LOOM_BENCH_REPEATS=9 \
LOOM_BENCH_PG_URL=postgres://... pnpm --filter @loom/bench-throughput bench:lease

LOOM_BENCH_PG_URL must point at a throwaway database: every run leaves its event, snapshot and lease rows (and the stores’ tables) behind. The Postgres pool is capped at 32 connections for OFF and ON alike, so the conc-256 cells are pool-bound as well as write-bound. The execs column is the count actually run (round(n / conc) * conc), not the configured n.

(LOOM_BENCH_SHAPES, LOOM_BENCH_EXECS / _SQLITE_EXECS / _PG_EXECS / _IO_EXECS, LOOM_BENCH_IO_MS also apply.) The non-blocking assertion form is leaseOverhead.perf.test.ts (pnpm test:perf). Batching itself is pinned deterministically (call counts, not time) by tools/dst/src/leaseBatching.test.ts: N concurrent executions cost one renewMany per chunk per tick and group-committed acquires/releases.

How to read this. The 10% gate is judged on the DURABLE backend (Postgres event store + Postgres lease store + Postgres snapshot store on one pool). The in-memory no-op is the worst case for a ratio (the baseline is almost free, so any fixed per-execution work is a large fraction of it) and is reported for that reason, not as the figure the gate is read on. SQLite is a single-host durable store (WAL, files in a temp dir).

Machine and caveats (local results, not CI). Apple M2, 8 cores, 8 GB, macOS (Darwin 25.6), Node v24.18.0, PostgreSQL 17.2 as a throwaway local cluster on loopback TCP (127.0.0.1, so no network latency; a networked Postgres adds a round trip per statement, which this does not price). 2026-10-15. N = 9 interleaved repeats per cell; 6000 in-memory / 2000 SQLite / 3000 Postgres no-op executions and 1500 I/O-bound at 20 ms per run. No loom job ran concurrently, but the desktop was not idle (a browser and window server; load average 6-15 on 8 cores during the run), so spreads are wide, especially in-memory (a single sample can fall to a third of its neighbours). “Overhead (median ratio)” is (median OFF - median ON) / median OFF; the paired columns are the same repeat’s OFF/ON pair (adjacent in time, so they cancel slow drift), as a median and a min..max range.

No-op (the throughput ceiling)

backendconcoff exec/son exec/soverhead (median ratio)paired medianpaired min..max
postgres165,7644,02430.2%30.3%-10.3..34.4%
postgres645,3003,99624.6%27.2%5.4..62.4%
postgres2565,2553,93325.2%25.6%1.0..33.0%
sqlite165,6525,2507.1%7.8%-33.7..14.8%
sqlite645,9775,9230.9%0.5%-18.7..3.0%
sqlite2565,8625,5525.3%6.3%-5.6..27.0%
memory16100,13976,19523.9%26.3%10.5..78.0%
memory6476,32361,61119.3%19.5%-117.5..40.7%
memory25674,81057,88822.6%23.7%-140.8..65.2%

I/O-bound (20 ms effect)

backendconcoff exec/son exec/soverhead (median ratio)paired medianpaired min..max
postgres165385311.4%1.4%-4.5..7.5%
postgres642,5392,4981.6%2.1%-6.1..17.4%
postgres2565,1783,52032.0%31.4%24.6..69.3%
sqlite16698699-0.0%0.3%-0.7..1.1%
sqlite642,7632,6862.8%2.7%-14.7..11.0%
sqlite2565,5114,28222.3%22.4%-16.9..77.0%
memory167247180.9%1.1%0.5..4.1%
memory642,8632,8420.7%1.0%-1.6..3.6%
memory25610,1249,7793.4%1.8%-4.9..11.8%

At concurrency 256 the I/O-bound shape stops being I/O-bound on the durable stores: 256 workers each waiting 20 ms could issue ~12,800 exec/s, above the ~5,000 exec/s the Postgres/SQLite write path sustains, so that cell is write-bound and reads like a no-op cell.

Does the gate hold?

  • Postgres (durable) no-op: NOT met. Median overhead is 25-30% at every concurrency (16: 30.2%, 64: 24.6%, 256: 25.2%), and the OFF/ON samples mostly do not overlap (the paired min at conc 16 is -10.3%; e.g. conc 16: OFF 5.1-6.0K vs ON 3.8-4.2K exec/s). This is not noise. Each execution pays two extra lease statements (one group-committed acquire, one release) on top of a handful of event-store statements, and on a write-bound store that is a large share of the work. On the numbers, Q8’s fallback (“lazy leasing”) condition is triggered for the durable backend; this document records the measurement, it does not change the default.
  • Postgres I/O-bound (conc 16 and 64): met (1.4% and 1.6%): the lease work overlaps the wait. The 256 cell is write-bound (see above) and shows 32%, the same cost as the no-op row.
  • SQLite no-op: met at 64 (0.9%), borderline at 16 and 256 (7.1%, 5.3% by median ratio, with paired ranges that straddle 10%).
  • In-memory no-op: not met (19-24%), as expected for the worst case. The in-memory stores make the baseline ~75-100K exec/s, so the fixed per-execution lease work is a large fraction of a tiny denominator. Nobody runs durable workloads on this configuration; it is the upper bound on the ratio, not the gate.

(S7g, below, takes that decision: the default became lazy.) These numbers say the Q8 decision needs to be taken on the durable-backend result above (accept the cost, batch more aggressively than the current group commit, or fall back to lazy leasing). A re-run on a fully idle machine and against a networked Postgres would tighten the figures; this run did not have either.

S7g: lazy heartbeat leases (ADR 0130 D11 amendment, 2026-10-15)

The S7f result above failed the D11 gate on the durable backend (eager: 25-30% no-op overhead on Postgres), so the default became lazy acquisition: a run takes no lease until it has been driving for lazyGraceMs (default TTL/3 = 10 s). (Amended 2026-10-15 after review: lazy applies to a FRESH start only; resume, resumeAs, restoreFromState, the send/notify winner and continue-as-new successors are eager in both modes. This bench starts fresh one-step flows and never resumes, so its numbers are the fresh-start path (the tables directly below were taken with CALLER-CHOSEN ids before the round-2 re-run in the last subsection; the intermediate commit that added a classifying hasSequenceZero read to every caller-chosen start was removed in round 2, since a start never drives an existing stream: see the re-run); the eager resume cost is the S7f protocol, priced by the S7f tables.) The bench (bench:lease) now measures three modes per repeat, interleaved with a rotating order: heartbeat off, eager (a lease before every run’s first append) and lazy (the default). The flow is one step, so with the 10 s grace a lazy run never leases: this prices the cost a short execution pays for lazy mode (a grace timer set and cleared, no store call), which is the ceiling shape the gate is about. A run that outlives the grace pays the eager cost from then on (one group-committed acquire, renewals, one release), amortised over a run long enough to be I/O-bound, where the S7f table shows it is within noise.

LOOM_BENCH_BACKENDS=memory,sqlite,postgres LOOM_BENCH_CONCS=16,64,256 LOOM_BENCH_REPEATS=7 \
LOOM_BENCH_EXECS=6000 LOOM_BENCH_SQLITE_EXECS=2000 LOOM_BENCH_PG_EXECS=3000 \
LOOM_BENCH_PG_URL=postgres://... pnpm --filter @loom/bench-throughput bench:lease

Same machine and caveats as above (Apple M2, 8 cores, macOS, Node v24.18.0, PostgreSQL 17 on loopback as a throwaway cluster with its own throwaway database, 2026-10-15; the desktop was not idle). N = 7 repeats per cell, OFF/EAGER/LAZY interleaved, medians. “Overhead” is (median OFF - median mode) / median OFF; a negative value means the mode measured faster than OFF (noise). Paired = the same repeat’s OFF/LAZY pair, as a median and a min..max range.

No-op (the throughput ceiling)

backendconcoff exec/seager exec/slazy exec/seager overheadlazy overheadlazy paired median (min..max)
postgres165,7594,1706,01227.6%-4.4%-5.6 (-11.7..2.4)%
postgres645,2653,7745,25628.3%0.2%1.0 (-10.6..7.9)%
postgres2565,1633,8985,33524.5%-3.3%-1.5 (-12.2..10.0)%
sqlite165,9695,3835,7829.8%3.1%1.7 (-11.4..9.7)%
sqlite646,0075,7086,1045.0%-1.6%-2.0 (-26.5..1.0)%
sqlite2565,7665,7096,0221.0%-4.4%-2.3 (-5.5..0.1)%
memory16106,01975,61487,26028.7%17.7%13.7 (-10.7..84.1)%
memory6475,38362,54274,34217.0%1.4%-0.9 (-34.4..72.3)%
memory25668,95559,88665,87013.2%4.5%7.7 (-89.7..19.3)%

I/O-bound (20 ms effect)

backendconcoff exec/seager exec/slazy exec/seager overheadlazy overheadlazy paired median (min..max)
postgres165435205374.2%1.1%0.7 (-1.7..5.1)%
postgres642,5222,5022,5230.8%-0.0%-2.4 (-16.5..12.9)%
postgres2565,2073,7925,24427.2%-0.7%0.3 (-25.6..27.2)%
sqlite16699699701-0.0%-0.3%-0.1 (-1.4..1.4)%
sqlite642,7452,6932,7751.9%-1.1%-0.3 (-7.6..1.2)%
sqlite2565,7264,1795,60727.0%2.1%2.2 (-7.3..4.6)%
memory167267217250.8%0.2%0.2 (-0.3..1.8)%
memory642,8752,7722,8483.6%0.9%1.0 (-0.7..2.2)%
memory25610,61010,37910,6692.2%-0.6%0.3 (-17.3..13.4)%

Does the gate hold with lazy as the default?

  • Postgres (durable) no-op: MET. Lazy overhead is -4.4%, 0.2% and -3.3% at concurrency 16, 64 and 256 (paired medians -5.6%, 1.0%, -1.5%), i.e. indistinguishable from heartbeat off, against eager’s 27.6%, 28.3% and 24.5% in the same run. The eager figure reproduces S7f.
  • Postgres I/O-bound: MET (lazy 1.1%, 0.0%, -0.7%; eager 4.2%, 0.8%, 27.2% at the write-bound 256 cell).
  • SQLite: MET (lazy 3.1%, -1.6%, -4.4% no-op; -0.3%, -1.1%, 2.1% I/O-bound).
  • In-memory no-op, the worst case for a ratio: the gate is not read here. Lazy is 17.7% at conc 16 (paired median 13.7%, range -10.7..84%), 1.4% at 64 and 4.5% at 256; eager is 28.7%, 17.0%, 13.2%. At ~100K exec/s the per-run grace timer (set, cleared) is a visible fraction of a ~10 microsecond run. The spread is wide (the machine was not idle), so only the direction is firm.
  • What lazy does NOT measure: the cost a run pays once it outlives the grace. That path is the eager protocol (S7f’s I/O-bound rows: 1-2% where the store is not saturated).

Round-2 re-run (after removing the classifying read; both id styles)

Review found that a classifying hasSequenceZero read on every caller-chosen start made the sentence above false. The read is gone: every start goes lazy, and an existing stream is refused by the sequence-0 bootstrap (ADR 0103) before any effect, with the loser’s grace timer cancelled and no lease row written (tested, including two racing lazy starts of one id on two nodes). Re-measured on Postgres 17 (loopback, throwaway cluster, own loombench database), Apple M2, desktop not idle, N = 5 repeats, OFF/EAGER/LAZY interleaved, medians, 3,000 executions per cell (LOOM_BENCH_IDS=caller|minted, new in this round). Caller-chosen ids (executionId supplied, which pays the sequence-0 barrier in all three modes) and minted ids (runtime-minted ulid):

idsshapeconcoff exec/seager exec/slazy exec/seager overheadlazy overheadlazy paired median (min..max)
callernoop165,8424,0955,83029.9%0.2%1.0 (-5.6..31.9)%
callernoop645,9744,1095,84031.2%2.3%2.3 (-1.7..36.1)%
callernoop2565,8123,9125,46232.7%6.0%-0.5 (-6.7..30.6)%
callerio165915625874.8%0.7%3.8 (-3.9..6.2)%
callerio642,7502,6522,7293.6%0.8%0.9 (-1.8..31.3)%
callerio2565,3173,9005,30226.6%0.3%0.6 (-60.3..1.1)%
mintednoop164,9833,7955,32923.9%-6.9%-6.7 (-15.6..2.3)%
mintednoop645,0613,8245,24724.4%-3.7%-2.0 (-6.6..22.1)%
mintednoop2564,7012,8534,39539.3%6.5%5.5 (-12.2..14.8)%
mintedio166076036080.6%-0.3%-0.3 (-1.2..1.8)%
mintedio642,7942,7112,7962.9%-0.1%-0.1 (-0.6..5.9)%
mintedio2564,3942,9354,70333.2%-7.0%-2.5 (-114.0..39.8)%

Both id styles meet the gate on Postgres: lazy is at most 6.5% on the no-op ceiling and at most 0.8% at I/O-bound concurrency 16 and 64 (the 256 cell is write-bound and reads like a no-op cell). Eager is 24-39%, reproducing S7f. Some lazy samples are single outliers (the paired max columns, e.g. 31.9%, are one slow repeat in the same run, where the OFF side was also noisy); the medians and the id-style comparison are the firm part. Caller vs minted does not differ for lazy beyond noise, which is the point: caller-chosen starts pay no extra read for lazy classification.

Source: docs/benchmarks.md in the loom repository.