Skip to content

Pop and consume messages with consumer groups

Pop a batch or run a consume loop: queue mode and consumer groups, where a new group starts, batches, leases and renewal, conflation, seek, and reads by offset.

Updated View as Markdown

There are two ways to read. consume() is a loop: it takes messages, calls your handler, acks them when the handler returns, and goes on. pop() is one call: it hands you the messages and leaves the ack to you.

Why
most workers are loops, and consume() writes the loop for you, with the error handling done right. pop() is for the cases a loop does not fit: a scheduled job that reads once, a script, or code that wants to decide itself when to ack.
The consume loop. It pops a batch, which leases the partition; calls your handler; acks when the handler returns, or fails the message when it throws; and goes back to pop. With autoAck off, your handler acks itself, often inside a transaction. pop() is one turn of the loop, done by hand.The consume loop. It pops a batch, which leases the partition; calls your handler; acks when the handler returns, or fails the message when it throws; and goes back to pop. With autoAck off, your handler acks itself, often inside a transaction. pop() is one turn of the loop, done by hand.
What consume() does for you.

Queue mode and consumer groups

Without a consumer group, each message goes to one worker, and the broker sizes every pop for you.

await queen.queue('orders')
  .each()                         // the handler gets one message at a time
  .limit(1)                       // stop after one message; a worker leaves this out
  .consume(async (msg) => {       // acked when the handler returns, retried if it throws
    console.log(msg.data)
  })

With a consumer group, every group gets every message and the workers of one group share them. A new group starts at the tail, so this one asks for everything already in the queue. Here every option is set by hand, and the handler acks.

await queen.queue('orders')
  .group('billing')               // every group gets every message
  .subscriptionMode('all')        // a new group starts at the tail unless told otherwise
  .batch(100)                     // up to 100 messages per pop...
  .partitions(10)                 // ...from up to 10 partitions
  .renewLease(true, 2000)         // heartbeat every 2 s while the handler runs
  .autoAck(false)                 // the handler decides the outcome
  .each()
  .limit(1)
  .consume(async (msg) => {
    const ok = msg.data.total > 0
    await queen.ack(msg, ok ? 'completed' : 'failed', { group: 'billing' })
  })

Ack failed and the message comes back, up to the queue’s retryLimit; after the last retry it goes to the dead-letter queue.

Why
one queue often feeds several services: billing, emails and analytics all want every order. Each service is a consumer group, reading at its own pace, and none of them can slow the others down. Inside a group, the workers compete, so you add workers to go faster.
Left, queue mode with no consumer group: the partitions of orders are shared out, and each message goes to exactly one of the workers w1, w2 and w3. Right, three consumer groups, billing, emails and analytics, on the same queue: each group gets every message and keeps its own position; inside billing, two workers split the partitions between them.Left, queue mode with no consumer group: the partitions of orders are shared out, and each message goes to exactly one of the workers w1, w2 and w3. Right, three consumer groups, billing, emails and analytics, on the same queue: each group gets every message and keeps its own position; inside billing, two workers split the partitions between them.
Queue mode shares the messages; every consumer group gets all of them.

One message at a time, or a whole batch

With .each(), the handler gets one message at a time. Without it, the handler gets the whole batch as an array.

await queen.queue('events')
  .group('archiver')
  .subscriptionMode('all')
  .batch(100)                         // up to 100 messages per call
  .limit(1000)
  .consume(async (messages) => {      // no .each(): the handler gets the whole batch
    console.log('archiving', messages.length)
  })                                  // all acked if it returns, all nacked if it throws

What the client does with your handler’s result (autoAck, on by default):

  • It returns: the message (or the whole batch) is acked.
  • It throws: the message (or the whole batch) is nacked as failed, which spends one retry, and the loop goes on with the next messages.
  • It throws under .each(): the rest of that partition’s messages in the same batch are skipped, because they will be delivered again after the failed one, in order. Messages of other partitions in the batch are handled as usual.
  • With .autoAck(false): the handler acks itself (in a transaction, for example). A handler that throws is still nacked, so its partition is not stuck until the lease runs out.

In the other SDKs: the list above is what JavaScript, Python and Go do. Under autoAck(false), Rust and C++ do not nack a handler that fails (its messages wait for their lease), and PHP stops the loop: catch the error and ack failed yourself.

Why
a batch is cheaper (one ack for a hundred messages, one database write for a hundred rows), and one at a time is simpler and fails one message instead of a hundred. Pick per worker.

If you add .onSuccess() or .onError() to a consumer, the client stops acking and nacking for you: you decide in those callbacks what happens to each message. Python and PHP have the same callbacks (PHP with autoAck(false)); Go, Rust and C++ have none, so their code turns autoAck off and acks in the handler.

await queen.queue('emails')
  .group('mailer')
  .subscriptionMode('all')
  .each()
  .limit(1)
  .consume(async (msg) => sendEmail(msg.data))
  .onSuccess((msg) => queen.ack(msg, 'completed', { group: 'mailer' }))
  .onError((msg, error) => queen.ack(msg, error.permanent ? 'dlq' : 'failed',
    { group: 'mailer', error: error.message }))
Why
some failures should be retried and some never will succeed (an email address that does not exist). The callbacks let you send the first kind back with failed and the second kind straight to the dead-letter queue with dlq.

How many messages a pop takes: autopilot

By default the broker decides how many messages each pop gets (batch, from 100 to 1,000, based on how fast the group acks) and from how many partitions (partitions, the ones that have work, shared among the waiting workers). A value you set yourself is never changed. partitions(N) lets one pop take messages from up to N partitions (1 to 64), with batch as the total. .autopilot(false) goes back to one message from one partition per pop.

Why
the right batch size depends on your handler and your load, and it changes during the day. The broker sees both, so it can size every pop better than a number fixed in the code.

Leases and long work

A pop leases its messages to the worker for the queue’s leaseTime. Work that can take longer renews the lease while it runs: .renewLease(true, 10000) renews every 10 seconds, and queen.renew(message) renews once. In PHP, the renewal happens between messages, so a single long handler calls the renew callback it is given. A lease that has already expired cannot be renewed: its messages go to the next worker, marked as one delivery attempt more.

Why
the lease is what protects order and stops two workers from doing the same work. A short lease gets a crashed worker’s messages moving again quickly; renewing lets a slow but healthy worker keep them.

Where a new group starts

A group’s starting point is decided on its first pop and then stored: new (the default) reads only what is pushed after that first pop, all reads everything still kept, and subscriptionFrom reads from a point in time. A group that already exists moves with a seek, to a time or to the end.

A partition with messages at offsets 0 to 7, pushed before a new group's first pop. subscriptionMode all starts the group before offset 0 and reads everything. subscriptionFrom a time starts it at the first message from that time, offset 4. The default, new, starts after offset 7 and skips all of them.A partition with messages at offsets 0 to 7, pushed before a new group's first pop. subscriptionMode all starts the group before offset 0 and reads everything. subscriptionFrom a time starts it at the first message from that time, offset 4. The default, new, starts after offset 7 and skips all of them.
Where a new group starts, fixed at its first pop.
await queen.admin.seekConsumerGroup('archiver', 'events', {
  timestamp: '2026-10-22T00:00:00Z',
})
// the archiver group now reads again everything pushed since that time

await queen.admin.seekConsumerGroup('archiver', 'events', { toEnd: true })
// and now it skips everything it has not read yet
Why
a new service usually wants only new events, a new projection wants the whole history, and a bug fix wants to replay the last day. A seek to the end is how a group skips a backlog it no longer needs.

Only the newest message: conflation

A group with .conflation() gets only the newest message of each partition, and its cursor moves past the older ones.

const [latest] = await queen.queue('prices')
  .group('repricer')
  .subscriptionMode('all')
  .conflation()                       // only the newest message of each partition
  .wait(false)
  .pop()
console.log(latest.data)              // { sku: 'A-1', price: 12 }: 10 and 11 are skipped
await queen.ack(latest, 'completed', { group: 'repricer' })
Why
some queues carry commands where only the latest one matters: “recompute the price of A-1”. A worker that is behind should do the work once, with the newest input, instead of three times with inputs that are already old.

Conflation is decided by the group’s first pop, needs a consumer group, and cannot be combined with commitOnDelivery.

Faster, with no ack: commitOnDelivery

A pop with .commitOnDelivery() moves the group’s cursor as the messages are handed out. There is no lease and nothing to ack.

const readings = await queen.queue('metrics')
  .group('live-chart')
  .subscriptionMode('all')
  .commitOnDelivery()                 // the cursor moves as they are handed out: no ack
  .batch(100)
  .wait(false)
  .pop()
Why
a live dashboard or a metrics chart can lose a reading now and then, and would rather not pay for leases and acks. This is at-most-once delivery: a worker that crashes after the pop loses that batch. It works with pop() only; consume() refuses it.

Running a consumer for real

const stop = new AbortController()
process.once('SIGTERM', () => stop.abort())     // a deploy stops the worker cleanly

await queen.queue('tasks')
  .group('workers')
  .subscriptionMode('all')
  .concurrency(4)                               // four handlers at once, on four partitions
  .renewLease(true, 10000)                      // long tasks keep their lease alive
  .idleMillis(60000)                            // or stop after a minute with nothing to do
  .each()
  .consume(async (task) => runTask(task.data), { signal: stop.signal })
  • concurrency(n) runs n handlers at once, each on its own partitions.
  • limit(n) stops after n messages, and idleMillis(ms) stops after that long with nothing to do.
  • wait(true) (the default) waits for messages instead of returning empty, up to timeoutMillis(ms) (30 seconds by default, at most 60).
  • A signal stops the loop at once. Messages it had taken but not started go back with a retry ack, which spends no retry, so a deploy does not leave partitions waiting for their leases to run out.

In the other SDKs: in 2.6.0 only JavaScript gives the unstarted messages back. Python, Go, Rust, PHP and C++ stop after the message they are handling, and the rest wait for their lease. Python, Rust and C++ also notice the stop only between two long polls, so their code sets a short poll timeout. PHP stops on SIGTERM by itself.

Why
a worker runs for weeks, through deploys and quiet nights. These options decide how much it does at once, when it stops, and how it gives its work back when it is told to.

Read it again

Consuming does not delete: the queue is a log, kept for its retention. A new group can start from a point in time and read the same orders again, while the groups that already read them stay where they are.

await queen.queue('orders')
  .group('audit')                           // a new group, with its own cursor
  .subscriptionFrom('2026-10-22T00:00:00Z') // everything pushed from this time on
  .each()
  .limit(1)
  .consume(async (msg) => {
    console.log('again:', msg.data)
  })

Or read a partition by offset with fetch: no group, no lease, no ack. It suits an audit, rebuilding one entity, or a sink that keeps its own position.

const read = await queen.queue('orders').fetch(['customer-42']) // no group, lease or ack
for (const record of read.get('customer-42').records) {
  console.log(record.offset, record.payload)
}
Why
history is useful long after the first reader is done with it: to build a new view, to audit what happened, or to rebuild one customer’s state from its events.

See also

Navigation

Type to search…

↑↓ navigate↵ selectEsc close