How-to › Run message-based services

How to limit concurrency in a message worker#

Stop a worker pulling more messages than it can process, so a burst waits at the broker where an operator can see it instead of inside your process.

Audience
Platform team
Level
advanced
Topic
Build message-based services
Languages
TypeScript and JavaScript
Verified

A backlog builds while you are asleep, the worker restarts, and it drains 8,000 messages at once. Memory climbs, the database refuses connections, and the handler that was fine at ten messages a second starts timing out at four hundred. The queue was the safety valve, and the worker took it apart by fetching everything it could.

What you get

You will end up with a worker that pulls only as many messages as it has free slots for. The backlog then waits at the broker, where queue depth is already a metric, instead of in an array inside one process. This is for you if you run consumers whose load you do not control.

Short answer

Put a bounded pool in front of the handler and ask the broker only for the number of free slots you have. The pool caps work in flight, and whatever you did not fetch stays in the queue where depth is already a metric. Size the bound against the scarcest downstream resource, which is almost always the database connection pool rather than the CPU.

You will need

Node 22 or later, and a worker that consumes from a queue under variable load. The sample stands in for the broker with a function that hands out at most what you ask for, which is the contract every real client offers under one name or another.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
A bounded pool with p-limitAny runtime, any broker, and you want the bound visible in your own codeA dependency, and a bound that does nothing unless fetching respects it tooThe client library already limits both fetching and processing
BullMQ concurrency optionsRedis-backed queues where you also want per-group rate limitingRedis as a hard dependency, and a limit that applies per worker processThe queue is not Redis, or you need one limit across the fleet
NATS queue groups with MaxAckPendingJetStream consumers, where the server can cap unacknowledged messagesAn acknowledgement deadline to tune, and redelivery when a handler outruns itYou need the cap enforced inside the process as well as at the server
Unbounded consumptionShort-lived jobs with no shared downstream resource, and a small fixed backlogNothing until a burst, then memory and every pool the handler sharesAnything that writes to a database, an API, or a disk

The choice is really about where the backlog sits. A server-side cap such as MaxAckPending keeps it in the broker for the whole fleet, which is the strongest form, and it needs a broker that offers one. An in-process pool keeps it in your own code, which works with any client, and it only helps if the fetch respects the same number. Setting the pool without changing the fetch produces a worker that still pulls the whole queue and then queues it internally.

Bound the pool, then bound the fetch

The pool is ten lines. The part that earns it is the loop that asks for free slots.

async function drain() {
  for (;;) {
    const free = concurrency - p.stats().inFlight
    if (free === 0) {
      await new Promise((r) => setImmediate(r))
      continue
    }
    const batch = await fetchBatch(free)
    if (batch.length === 0) break
    pulled += batch.length
    for (const message of batch) {
      // Two handlers, not one. A rejected task with nothing to catch it is an
      // unhandled rejection, and Node ends the process on one of those, so a
      // single poison message would take the whole worker down with it.
      p.run(() => handle(message)).then(
        () => { processed++ },
        (err) => { failures.push({ message, error: err.message }) },
      )
    }
  }

fetchBatch(free) is the whole idea. Every broker client has a form of it: a prefetch count on an AMQP channel,1 a MaxNumberOfMessages argument on an SQS receive call,2 a MaxAckPending setting on a JetStream consumer. Passing the number of free slots turns the pool from a decoration into back pressure that reaches the broker.

Release the slot in a finally. A handler that throws and a handler that returns have to free the same slot, and a pool that leaks one slot per poison message degrades to serial and then to stopped.

Watch the backlog move

Run the same 40 messages through four bounds and watch where the unprocessed remainder sits.

node demo.mjs
bounded to 1   pulled 40  processed 40  peak in flight  1  most left at the broker 39
bounded to 4   pulled 40  processed 40  peak in flight  4  most left at the broker 36
bounded to 16  pulled 40  processed 40  peak in flight 16  most left at the broker 24
unbounded      pulled 40  processed 40  peak in flight 40  most left at the broker 0

Every row processed all 40 messages. The last column is the difference. The bounded runs left work in the queue while they worked. The unbounded run pulled the whole queue into memory and reported an empty backlog for the entire run. A dashboard watching queue depth would have shown nothing wrong right up to the moment the process died.

Check it worked

The tests pin the two properties that matter, and the one that bites during an incident.

node --test pool.test.mjs
1..5
# tests 5
# suites 0
# pass 5
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 154.898284

Test three asserts that the worker never asks the broker for more than its free slots. Check that against your own client rather than trusting the setting name. Several clients treat the number as a hint and deliver more, and a few reset it on reconnect, which turns a tuned worker back into an unbounded one after a network blip.

The last two are the ones to keep. A handler that throws has to free its slot exactly as a handler that returns does. A pool that leaks one slot per poison message gets slower for days and then stops, and the symptom by then looks nothing like the cause.

The fifth test costs one poison message in a batch of three, and without a rejection handler on the pooled task it does not fail, it ends the process. Node treats an unhandled rejection as fatal, so one bad message takes down a worker that was holding two good ones.3 Give the task both callbacks, count what failed, and let the batch finish.

When it goes wrong

Connection timeouts appear under load and the CPU is idle. Worker concurrency is higher than the database pool size, so handlers queue for a connection and exceed the acquire timeout. Set concurrency at or below the pool size, and treat the pool as the real limit.

Messages are redelivered although the handler succeeded. The work took longer than the acknowledge deadline because it spent most of its time waiting for a slot. Extend the deadline, or lower the number of messages the broker considers delivered.

The backlog grows while every call stays fast. The bound is below what the service can sustain. Raise it one step at a time, and stop at the point where latency starts rising rather than at the point where throughput stops.

When not to do this

Do not add back pressure without an alert on queue depth. Pushing work back to the broker hides load from every dashboard that watches the worker, and the worker looks healthy while a customer waits an hour. Alert on the queue, not on the consumer. Queue depth, oldest message age, and the ratio of the two are the three numbers that tell an operator whether a bound is protecting the service or hiding a stall.

Do not bound concurrency to fix a slow handler. A limit of two on a handler that takes 30 seconds is a throughput ceiling you will hit forever. Find the wait first, and set the bound afterwards.

Do not share one number across handlers that lean on different resources. A consumer writing to PostgreSQL and one calling a vendor API have different scarce resources, and a single global limit either starves the first or floods the second. Give each its own pool.

Last verified

Verified 2026-09-14 against Node 22.22.2. Both output blocks are what the preceding command printed.

Footnotes

  1. The word prefetch names a setting whose scope moved. AMQP 0-9-1 defines basic.qos on a channel, and RabbitMQ’s documentation explains that a channel can consume from several queues, which would have every queue coordinating with every other on every message. So RabbitMQ applies the count to each consumer instead, and says, in as many words, that it slightly deviates from the specification on the point. A value of 0 is treated as infinite, which is the setting the page opens with, under its formal name. ↩︎ Back to text

  2. Ten is the ceiling. Amazon’s reference for the call gives MaxNumberOfMessages valid values of 1 to 10 and a default of 1, and adds that fewer messages might be returned than asked for. A worker with 16 free slots therefore asks twice, and a worker with 40 asks four times, which is a bound the broker imposes on your bound. The documentation does not say why ten. Ten is the number it is. ↩︎ Back to text

  3. The fatality is a decision, and a reversal. Node’s command line reference records that --unhandled-rejections gained its throw default in version 15.0.0, and that before that a rejection nobody caught produced a warning. A worker written for Node 14 could drop a message and carry on. The same worker on Node 22 exits, taking the two good messages with it. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.