Skip to content
Get started
Get started
Engineering Benchmarks: Queue and Workflow Engine
native engineering campaign · 2026-07-30

Benchmarks measured,
not mixed.

Every number names the operation, persistence boundary, topology, scale, sample count and integrity check. A public on-disk add is not an internal in-memory batch, and producer ingestion is not worker drain.

729K jobs/sec internal in-memory batch push159K jobs/sec TCP PUSHB, on-disk broker3.2K linear workflows/sec, one TCP Engine

The current campaign ran natively on an AMD Ryzen 9 9950X3D (16 cores / 32 threads), 59 GiB RAM, Linux 7.0.0-14, CPU governor performance, Bun 1.3.14, revision af027f04d2064b701ee2243eac737d74b8d87706. Docker was used only for the functional sandbox and was stopped before performance measurement.

These labels are not interchangeable:

LabelWhat it means
Internal in-memoryDirect QueueManager batch API, no dataPath, no SQLite write
Embedded on-diskPublic Queue API and a fresh SQLite dataPath
TCP on-diskPublic client over localhost MessagePack to a broker with its own SQLite file
BufferedNormal write-behind persistence, with the documented ≤10 ms hard-crash window
Durabledurable:true; the timed call waits for synchronous persistence
WorkflowA complete multi-node execution, not one queue operation

All rows below are medians. They describe different workloads and should not be divided into an “Embedded is N× TCP” claim.

OperationSamplesMedianVariability / tailIntegrity
Internal in-memory batched push, 1M jobs21729,395 jobs/s693,001–747,943; CV 1.94%21M/21M IDs and payloads
Internal in-memory batched process, 1M jobs21541,712 jobs/s523,286–558,036; CV 1.40%pull + completed event
Internal in-memory full lifecycle, 1M jobs21311,915 jobs/s303,674–316,556; CV 1.21%clean final state
TCP pipelined individual push2180,978 ops/s78,331–83,806; CV 2.06%fresh broker/database
TCP PUSHB, 50K jobs21158,779 jobs/s146,496–163,501; CV 3.25%fresh broker/database
TCP no-work worker drain, concurrency 502117,256 jobs/sp05 16,455; p95 17,864; CV 2.86%420K/420K exactly once

The 1M-job runner uses 16 queues and workers, removes completed jobs, and checks the complete ID set both when pulled and on the completed event. It is the engine’s maximum-throughput path, not the public Queue API.

bench:pushbulk ran eight fresh campaigns; the first was discarded. Each cell is already a median of three repetitions. This is the median of seven campaign medians for the final 50K cell:

Modeadd()addBulk()
Embedded, on-disk147,818 jobs/s186,384 jobs/s
TCP, on-disk127,476 jobs/s87,319 jobs/s

This runner grows one database through 1K, 5K, 10K and 50K cells. The 50K row is deliberately a sustained/grown-database result. The isolated 159K TCP PUSHB row above is the clean-database bulk result.

After the TCP throughput campaign, 5,000 sequential localhost adds measured 14 µs p50 / 292 µs p99.

For durable:true, every fresh process timed 2,000 sequential operations after 50 warm-ups:

ModeRunsThroughput medianp05 / p95Latency p50 / p95 / p99
Embedded2160,835 ops/s53,698 / 63,00215 / 25 / 59 µs
TCP2127,191 ops/s21,590 / 29,08930 / 57 / 120 µs

All 43,050 operations per mode, including warm-ups, were present in queue counts.

A single TCP worker drained 10K preloaded jobs from a fresh broker/database in each sample:

ConcurrencyMedian drain
3217,607 jobs/s
4817,541 jobs/s
6417,191 jobs/s
9615,830 jobs/s
12815,094 jobs/s
19214,081 jobs/s

The useful knee for this localhost no-work processor is 32–48. More concurrency is slower. Producer ingestion and worker drain are separate capacity limits.

bun run bench:workflow uses the public Engine facade and four graphs:

ScenarioGraphRequired terminal state
Linearvalidate → transform → persistcompleted; 3 steps
Parallelprepare → 3 inline parallel steps → joincompleted; 5 steps
Compensationreserve → charge → intentional failurefailed; rollback completed; 2 reversals
Signalrequest → wait for approval → finishwaited and signalled exactly once; completed

Every sample has a new process, queue, workflow SQLite file, and name. TCP adds a new broker process, broker database, and dynamic ports. The final tuning sweep selected Embedded concurrency 128 and TCP concurrency 64.

Mode / scenarioRuns × executionsMedianp05 / p95CVRun-median p95 latency
Embedded linear21 × 1,0002,700 wf/s2,570 / 2,7922.86%342.8 ms
Embedded parallel7 × 5002,118 wf/s1,997 / 2,1732.70%219.8 ms
Embedded compensation7 × 5002,055 wf/s2,005 / 2,1182.05%225.6 ms
Embedded signal7 × 5001,928 wf/s1,883 / 1,9611.39%84.7 ms resume
TCP linear21 × 1,0003,187 wf/s3,075 / 3,2611.77%284.3 ms
TCP parallel7 × 5002,456 wf/s2,368 / 2,5282.48%186.4 ms
TCP compensation7 × 5002,239 wf/s2,218 / 2,3842.87%200.9 ms
TCP signal7 × 5002,234 wf/s2,204 / 2,3061.72%64.9 ms resume

These are saturated-batch workflow:started-event-to-terminal latencies, including time behind other executions after the lifecycle event begins. Throughput starts before the first Engine.start() call. Neither metric is idle single-workflow service latency.

TCP can be faster here because the broker and Workflow Store run in separate processes with separate SQLite files. Embedded uses two connections to the same file. The result does not say that network transport is cheaper.

Signal phase medians:

ModeParkResumePark p95Resume p95
Embedded3,205 wf/s4,837 wf/s139.1 ms84.7 ms
TCP3,518 wf/s6,149 wf/s123.0 ms64.9 ms

Across both modes the measured single-engine campaign reconciled 63,000 executions, 189,000 step completions, 56,000 successful workflows, 7,000 intentional failures and 14,000 compensation outcomes.

The broker protects each protocol client with a default 10,000-request, 60-second sliding window. A workflow expands into multiple queue commands:

Linear executionsDefault result
3,0004,005 wf/s; 0.749 s
3,50058 wf/s; 60.159 s; p95 60.088 s

At the plateau, persisted state showed 896 workflows waiting for their last node, 896 broker jobs waiting and 34 active. At window turnover, ACK batches reported Rate limit exceeded, then every workflow completed. Lowering TCP frame timeout, command timeout or worker cleanup to 5 seconds did not move the plateau. Raising only RATE_LIMIT_MAX_REQUESTS=1000000 restored 3,500 executions to a 3,855 wf/s median across three fresh runs.

That is why default and tuned results are separate. In production, size the protocol limit from expected command expansion and monitor rate-limit hits.

The scale runner launched independent engines behind a sub-millisecond common barrier: 5,000 executions per instance, one discarded warm-up and three measured campaigns. TCP used RATE_LIMIT_MAX_REQUESTS=1000000.

Mode / instancesMedianSpeedupEfficiencyPeak sampled CPU / RSS
Embedded ×13,194 wf/s1.00×100%77% / 233 MiB
Embedded ×410,579 wf/s3.31×82.8%317% / 894 MiB
Embedded ×819,379 wf/s6.07×75.8%652% / 1,745 MiB
Embedded ×1225,873 wf/s8.10×67.5%1,028% / 2,549 MiB
TCP ×14,207 wf/s1.00×100%156% / 352 MiB
TCP ×411,738 wf/s2.79×69.8%671% / 1,371 MiB
TCP ×817,407 wf/s4.14×51.7%1,497% / 2,626 MiB
TCP ×1217,496 wf/s4.16×34.7%2,415% / 3,861 MiB

TCP is effectively saturated at ×8 on this shared host. The runner checked 750,000 measured plus 250,000 warm-up workflow executions; every child passed the same integrity scan.

The separate queue-engine scale test used much larger internal workloads:

TopologyMedian aggregateCorrectnessPeak resources
12 Embedded instances × 1M jobs2,008,704 jobs/s lifecycle36M / 36M~2,513% CPU; 37.4 GiB RSS; no swap
12 TCP broker/client pairs × 50K46,937 jobs/s drain1.8M / 1.8M~2,249% CPU; 5.2 GiB RSS

The Embedded integrity sets intentionally retain 12 million IDs at once and dominate RSS. TCP gained about 2.7× over one pair, not 12×, because every pair shares one scheduler, loopback stack and storage device.

Terminal window
git clone https://github.com/egeominotti/bunqueue.git
cd bunqueue
bun install
# Core queue runners
bun run src/benchmark/million-jobs.bench.ts
BENCH_N=50000 BENCH_RUNS=21 bun run bench:tcp
# The published push/bulk Embedded row is on-disk. Use a fresh path and point
# BENCH_HOST/BENCH_PORT at a separately started fresh SQLite broker.
BUNQUEUE_DATA_PATH=/tmp/bunqueue-bench-embedded.db \
BENCH_HOST=127.0.0.1 BENCH_PORT=16794 \
bun run bench:pushbulk
# Workflow Engine, both modes and all scenarios
BENCH_OUTPUT=/tmp/workflow.json bun run bench:workflow
# Tuned host scale
RATE_LIMIT_MAX_REQUESTS=1000000 \
BENCH_OUTPUT=/tmp/workflow-scale.json \
bun run bench:workflow:scale

Run without BUNQUEUE_EMBEDDED=1; that test variable forces clients into Embedded mode and would invalidate a TCP label.

The bare bun run bench:pushbulk command does not configure an Embedded dataPath; its Embedded half is in-memory. It must not be used to reproduce an on-disk row unless BUNQUEUE_DATA_PATH points to a fresh SQLite file. The TCP half likewise requires a fresh broker/database at the explicitly reported endpoint.

The runners and report are in the repository:

Before native measurement, the isolated sandbox passed 6,250 unit/model tests, 430 TCP integration tests and 273 Embedded integration tests, with zero failures. A mixed-suite memory-growth signal was followed by three focused TCP chaos soaks: 90K jobs, 81 worker kill/reconnect cycles, flat post-cold p99, bounded 4.1–4.3 MB WAL and post-GC collection bounds all passed.

The July 8 Apple M1 Max comparison remains available as a dated, separate campaign: bunqueue TCP bulk push 85,700 jobs/s versus BullMQ 24,800, while individual concurrent push was 52,756 versus 56,736. It used bench/comparison/run.ts, BullMQ 5.79.3 and Redis 8.8.0. Do not combine those values with the Ryzen campaign above.

Read bunqueue vs BullMQ or the original benchmark article.