Skip to content

Performance

Reproduce with npx tsx scripts/bench.ts (Docker required — it starts Postgres 17 via Testcontainers). Workload: 200 workflows × 6 steps in a root → 4 parallel → join diamond. Handlers are no-ops, so this measures what the engine costs per step — claiming it, reading dependency outputs, persisting the transition, recomputing readiness — not your work.

Engine + Postgres store (in-process dispatcher), per-step latency:

concurrency steps/sec p50 p95 p99
1 1,031 1.0 ms 2.1 ms 2.9 ms
4 1,932 2.1 ms 3.9 ms 4.8 ms
16 2,108 7.1 ms 12.7 ms 15.9 ms
64 2,270 26.8 ms 46.8 ms 64.0 ms

End-to-end through pg-boss workers — the full production path, batch 25:

workers burstWhenBatchFull concurrency steps/sec
1 off 1 50
1 on 1 274
1 on 8 646
4 on 8 902

That first row is not a ceiling, it’s a polling artifact — and the fix is configuration, not architecture. A worker drains a batch in milliseconds, then waits out the 0.5 s interval, so burstWhenBatchFull is the setting that matters: it keeps fetching while batches come back full. concurrency (steps run at once from one batch) then compounds on top — but on its own, without burst, it changes nothing at all, because the wait, not the work, is the bottleneck.

Budget connections before raising concurrency: each in-flight step holds one, so workers × concurrency must fit your pool and Postgres max_connections.

How to read this. Measured on an M-series Mac with Postgres in Docker, which has markedly slower disk I/O than a Linux host — expect better on a real server. These are an order of magnitude and a scaling shape, not a score. A Redis-backed job queue will beat these numbers outright, because it isn’t writing a durable transition per step to a relational database; that write is the feature. And in any real workflow, handler time dwarfs the 1–3 ms of engine overhead, so the practical question is usually whether ~1 ms per transition is acceptable next to what your steps actually do.