Skip to content
Get started
Get started
bunqueue v2.9.21
View Markdown
blog · release

bunqueue v2.9.21

TCP workers stop receiving events they never use: draining a backlog is 8–16% faster with one worker, 27–35% faster with four worker processes and 51–60% faster with eight. Typed arrays in job data now arrive with their real bytes over TCP and from SQLite, and two bugs in embedded counts and Simple Mode cancellation are fixed.

Adding a worker process should make a queue faster, never slower. v2.9.20 got closer to that promise by writing each batch of events to a worker in one socket call. This release asks a blunter question: why send most of those events at all?

A TCP Worker listens to its queue’s events for exactly one reason: to notice stalled jobs. Until now the server still sent every worker the pulled and completed event of every job. Each worker process decoded that traffic and threw almost all of it away. Eight worker processes meant eight copies of it.

v2.9.21 stops sending it. On the final release code, four worker processes drain a backlog 27–35% faster and eight 51–60% faster than on v2.9.20. Along the way we found and fixed a bug that silently corrupted every Float32Array sent over TCP.

bun add bunqueue@2.9.21
npm install bunqueue@2.9.21
TAG=2.9.21
docker run -p 6789:6789 -p 6790:6790 ghcr.io/egeominotti/bunqueue:$TAG
bun add bunqueue@latest

Eight worker processes drain a backlog up to 60% faster

Section titled “Eight worker processes drain a backlog up to 60% faster”

The event stream exists for QueueEvents and dashboards, which want to see every job move. A Worker is not that kind of listener, but it used the same unfiltered subscription. Every job it pulled produced a pulled event and every job it finished produced a completed event. The server encoded each of them and wrote it to every connected worker, and each worker decoded it only to drop it:

Event traffic from the server to four worker processes, before and after v2.9.21 v2.9.20: every event, to every worker v2.9.21: only what a worker asked for server server workerworkerworkerworker workerworkerworkerworker pulled + completed for every job, ×4 stalled only, when a job stalls
Before, every worker received the pulled and completed event of every job, so the total traffic grew with every extra worker process. Now a worker receives only stalled events.

The fix is a filter on the subscription itself. SubscribeEvents takes an optional eventTypes list of 1 to 32 names. The server sends a filtered connection only those types, and skips encoding an event that no subscriber of the queue accepts. A Worker now subscribes like this:

{ cmd: 'SubscribeEvents', queue: 'emails', eventTypes: ['stalled'] }

The effect is easiest to see in how often a worker process wakes up to read its socket. Draining 10,000 jobs (one instrumented run each), the worker processes together performed:

Worker processesSocket reads in v2.9.20Socket reads in v2.9.21
121,7331,712
481,7771,786
8161,5731,832

In v2.9.20 the number of reads grew with the number of processes: eight processes did 7.4 times the reads of one, for the same 10,000 jobs. Now it stays flat, and the CPU it used to cost goes back into processing jobs.

We measured the final release code against v2.9.20 three times, draining a backlog over TCP with a memory-mode server. The first run used six samples per version in rotating order at 100,000 jobs. A full ABBA run then took five samples at 100,000 jobs and three at 1,000,000:

Workersv2.9.20 → v2.9.21, rotating, 100kABBA, 100kABBA, 1M
1 Worker, in the producer’s process72,638 → 84,120 jobs/s (+15.8%)+13.2%+13.1%
1 worker process69,690 → 78,435 jobs/s (+12.5%)+8.3%+12.8%
4 worker processes87,539 → 117,750 jobs/s (+34.5%)+27.3%+30.2%
8 worker processes69,974 → 111,780 jobs/s (+59.7%)+51.1%+55.2%

The more processes, the bigger the win, because that is where the duplicated traffic was largest. With four and eight processes the ranges never overlap in any run. With one worker they overlap in some ABBA rows: at both sizes for one worker process, and at 100,000 jobs for the in-process Worker.

We also report what did not move, or might have moved the wrong way:

  • Adding jobs to a memory-mode server (addSeq, add, addBulk) moved by -7.8% to +0.9% across the three runs. The largest drop was sequential addSeq at 100,000 jobs in the ABBA run. It did not repeat in the rotating run (-1.4%) or at 1,000,000 jobs (-3.3%).
  • Adding jobs to an SQLite server measured -9.1% to -0.8% in the ABBA run, all with overlapping ranges. The largest was again addSeq at 100,000 jobs.
  • Draining from SQLite in embedded mode moved by -0.9% to +5.8%, with overlapping ranges.

For the add paths we can neither confirm a regression nor rule out a small one, and we don’t count the SQLite drain as a gain.

Without eventTypes a connection still receives every type, so QueueEvents, dashboards and existing clients get exactly the bytes they got before. A server older than this release ignores the field and keeps sending everything, and the Worker still filters by event.eventType itself. That means a new Worker works against an old server, and an old Worker against a new one.

A CPU profile found it. We profiled a TCP drain with the producer and the Worker in one client process. handleData, the function that reads the socket, took 52.6% of that process’s total time. The function with the most self time was msgpackr’s readMap, at 21.1%: the worker was decoding frames, mostly events, only to drop them.

To check that the filter changed only that, we then wrapped the functions of bunqueue’s queue, worker, client and broker in a call counter. We ran 19 scenarios through both versions: embedded and TCP, memory and SQLite, adds, bulk adds, drains, and one, four and eight worker processes. That gave a matrix of 169 functions by 19 scenarios, at 1,000 and 10,000 jobs, plus a CPU profile of each scenario.

Most rows of the matrix are identical between the versions, which is the point: the job lifecycle does the same work. The rows that differ are the ones the change was meant to move:

Scenario (10,000 jobs)Functionv2.9.20v2.9.21
8 worker processesTcpClientHealth.handleData (worker side)161,5731,832
1 worker processTcpClientHealth.handleData (worker side)21,7331,712
Embedded drain, SQLiteQueueManagerQueries.getJob10,0000

The last row is a smaller change shipped alongside it. After every completion, the flow-completion check looked the job up again to learn its parent, once per job. It now uses the job the completion has just retained in memory, and it settles on the same microtask as the old lookup, so the order of dashboard events does not change. In the same pass, deleting a flow failure now reuses one cached SQLite statement instead of compiling a new one for every completion.

A second problem surfaced during the same work, and this one was a correctness bug. Send a Float32Array over TCP and look at what a worker receives:

The eight bytes of Float32Array [1.5, 2.5] in memory, as v2.9.20 sent them, and as v2.9.21 sends them in memory v2.9.20 sent v2.9.21 sends 0000c03f 00002040 0102 ???????????? 0000c03f 00002040 1.52.5
new Float32Array([1.5, 2.5]) is eight bytes. v2.9.20 sent each float cut down to a single byte (1 and 2), followed by six leftover bytes from earlier frames that changed from run to run.

The cause is in msgpackr, the MessagePack library bunqueue uses. Without its moreTypes option, it writes any TypedArray as a binary value of the right length, but fills it with Uint8Array.prototype.set. That copies element by element and converts each element to a byte. 1.5 becomes 1 and 2.5 becomes 2, and the other six bytes keep whatever the library’s shared output buffer held from earlier frames. Those can be another job’s data: in a reproduction with the v2.9.20 encoder, 58 of 2,000 frames carrying a Float32Array also carried bytes of a different job’s payload. With v2.9.21, none did. A DataView arrived empty, and a BigInt64Array made a TCP or durable add throw. Every non-byte TypedArray was hit: Uint16Array, Int32Array, Float64Array and the rest, over TCP and from SQLite after a restart. An embedded memory-mode queue never encodes job data, so it was not affected.

A fix that costs nothing on ordinary frames

Section titled “A fix that costs nothing on ordinary frames”

Rewriting every frame to look for views would slow down the jobs that carry plain JSON-like data, which is most of them. So the encoder asks msgpackr instead. msgpackr reads its moreTypes option only when the value it is writing depends on it: a TypedArray that is not a Buffer, a DataView, an ArrayBuffer, a Set, an Error or a RegExp. We give it an option object whose moreTypes is a getter that returns false, as the default does, and counts how often it is read:

  • The getter was never read: the frame cannot contain a view, so it is sent exactly as before, in one pass.
  • The getter was read: a scan checks for a non-byte view. If there is none (for example plain Uint8Arrays), the bytes already written are kept.
  • There is a non-byte view: the value is rewritten copy-on-write, each view replaced by a Uint8Array over its own bytes, without copying the data, and encoded again.

The result: every frame without such a view is byte-identical to before, checked across 53,547 values. On the drain benchmarks above, the final code and the commit just before the fix differ by -0.6% to +1.7%. In our encode micro-benchmark of a PUSHB frame, one that carries a plain Uint8Array pays one extra scan, about 9% of its encode time. One that carries a Float32Array takes about twice as long to encode, and in exchange it now carries the right bytes. Class instances and types an app encodes with its own msgpackr addExtension are left alone.

A TCP or SQLite reader gets a Uint8Array, because MessagePack has no type for “array of floats”. To turn it back into numbers, copy the bytes into a new Uint8Array first: a decoded view can start at any offset of a shared buffer, and a Float32Array over that buffer rejects an unaligned offset.

const bytes = job.data.vector as Uint8Array;
const vector = new Float32Array(new Uint8Array(bytes).buffer); // [1.5, 2.5]

Rows stored with corrupt bytes before this release are not repaired. The TypeScript SDK (bunqueue-client) gets the same fix in its next release: it currently refuses these views with SerializationError.

msgpackr 2.1.3 is out, so we tested whether upgrading would fix the bug for free. It does not: 2.1.3 still copies TypedArrays element by element. It also changes how the subclasses of a few built-in types (Error, Set, RegExp, ArrayBuffer, TypedArray) that define their own toJSON are encoded, which would change what some jobs store. For example, an HTTP client error whose toJSON includes request headers would start persisting them. It also showed no consistent throughput gain in our A/B runs. bunqueue stays on msgpackr 1.x until those changes can be handled deliberately.

  • Benchmark against BullMQ. A new Benchmark vs BullMQ page compares bunqueue 2.9.20 with BullMQ 6.4.2 on Redis 8.10.2, in memory and on disk. The machine was bare-metal Debian 13 on an AMD Ryzen 9 7950X3D, and every number is the median of five runs with its range. Both wins and losses are reported:
    • In memory, bunqueue adds jobs 1.3–4x faster, drains a backlog 1.1–1.3x faster and starts delayed jobs 8.5x (median) closer to their due time.
    • Median pickup latency is level, and BullMQ has the lower p99 at 5,000 and 10,000 jobs/s.
    • On disk, Redis with AOF is ahead: about 7x on draining a backlog.
    • With a million queued jobs, bunqueue holds about 2.2 GiB and Redis about 0.6 GiB.
    • The homepage shows the results right after the hero, and bench/vs-bullmq is the reproducible harness behind them.
  • The bunqueue-assistant agent knows the whole API. It now carries a matrix of every public surface, checked against the source: Queue, Worker, flows, workflows, MCP tools, the 107 TCP commands, the HTTP endpoints, the CLI and the environment variables. For each one it describes the behavior in embedded mode and over TCP.
  • Embedded getDelayedCount() counts every delayed job. It counted one default getJobs page, so it never reported more than 100: a queue with 250 delayed jobs answered 100. It now uses the same counts as getJobCounts(), as over TCP.
  • Simple Mode processors see the Worker’s aborts. A Bunqueue processor kept running after its job had ended elsewhere, because the wrapper dropped the Worker’s signal. That happened on a server-enforced timeout, app.worker.cancelJob(id), cancelAllJobs() and a forced worker close. Now app.getSignal(job.id) is aborted with the Worker’s reason, and any pending in-process retry stops. A processor that honours the abort counts it as a failure for the circuit breaker, as app.cancel() already did, so cancelling many running jobs with cancelAllJobs() can now open the circuit.

The throughput figures were measured on native macOS 27.2 on an Apple M5 Max (18 cores, 64 GB), with Bun 1.4.4 and the final 2.9.21 code.

  • Baseline: the v2.9.20 column is commit 588fccb8, which is v2.9.20 plus a documentation-only change.
  • Samples: each sample used a fresh server, database and ports. Each row is the median of six samples in an order that rotated every round, or of the ABBA samples where a table says so.
  • Workers: concurrency: 50, batchSize: 20 and heartbeatInterval: 5000.
  • Clients: pingInterval: 0; the producer used a pool of 8 connections.
  • Socket reads and call counts: one instrumented run per scenario. The instrumentation slows the code down, so those runs are used for counts, never for throughput.
  • Stability: 24 runs of 2.9.21, in embedded and TCP, memory and SQLite, with one and four worker processes, at 1,000, 10,000, 100,000 and 1,000,000 jobs. Every run delivered each job exactly once and left an empty queue with no errors.

← bunqueue v2.9.20 · Release on GitHub · Commits since v2.9.20 · Full changelog · All posts