RabbitMQ unacked messages piling up look like this: you open the management UI, watch a queue, and the Unacked column keeps climbing while Ready drains. Messages are being delivered but never confirmed. Left alone it reads like a stuck queue, and eventually the consumer stops receiving anything at all. Here is what "unacked" means, why it grows, and how to fix each cause.
What "unacked" actually means
RabbitMQ shows three numbers per queue:
- Ready - messages waiting, not yet sent to any consumer.
- Unacked - messages delivered to a consumer but not yet acknowledged.
- Total - the two added together.
An unacked message is in limbo. The broker has handed it out and is holding it in memory,
waiting for the consumer to say "done" with a basic_ack. Until that ack (or the consumer
disconnects), the broker will not delete it and will not give it to anyone else. A few
unacked messages is normal - that's just work in progress. Unacked that only ever grows
is the bug.
This is also why a broken consumer looks dead rather than slow. Unacked messages count against the consumer's prefetch limit, so a consumer that stops acking fills its prefetch window and then RabbitMQ sends it nothing more. With a prefetch of 10 it goes silent after exactly 10 deliveries. It has not disconnected; it is simply full of messages it never finished. This builds directly on message acknowledgements and the acknowledgements deep dive.
Cause 1: the consumer forgot to ack
The most common cause. You're consuming in manual-ack mode but your callback never calls
basic_ack. Every delivered message stays unacked forever.
// BROKEN: no ack, so every message sticks in "unacked".
$channel->basic_consume($queue, '', false, false, false, false, function ($msg) {
handle($msg->body);
// ...and then nothing. The broker is still waiting.
});
The fix is to acknowledge after the work succeeds:
$channel->basic_consume($queue, '', false, false, false, false, function ($msg) {
handle($msg->body);
$msg->ack(); // tell the broker this one is done
});
If you're on Laravel, the driver acks for you when the Job's handle() returns without
throwing - so this manual mistake is a hand-rolled-consumer problem, not a Laravel one.
Cause 2: an exception is thrown before the ack
Your ack is there, but the code above it throws, so the ack line never runs. The message stays unacked while the consumer moves on to the next delivery - or crashes.
function ($msg) {
handle($msg->body); // throws on a bad message
$msg->ack(); // never reached
}
Wrap the work so you make a deliberate decision on failure - ack, or reject/nack so the message is redelivered or dead-lettered rather than silently stuck:
function ($msg) {
try {
handle($msg->body);
$msg->ack();
} catch (\Throwable $e) {
// don't requeue a poison message forever - send it for dead-lettering
$msg->reject(false);
}
}
Blindly requeueing a message that always throws creates a poison message loop - it's redelivered, throws, redelivered, forever. Route persistent failures to a dead-letter queue instead, as in retries and dead-letter queues.
Cause 3: prefetch too high with slow work
Even with correct acks, a high (or unlimited) prefetch lets one consumer grab a large batch of messages up front. They all show as unacked until that consumer works through them one by one. If each takes a while, the Unacked number stays large the whole time - and worse, other idle consumers get nothing because the messages are already reserved.
Set a prefetch that matches how much a consumer can genuinely process at once. For slow jobs, one at a time:
$channel->basic_qos(null, 1, null); // deliver 1 unacked message at a time
This is fair dispatch / prefetch doing exactly its job: with prefetch 1, a consumer only ever holds one unacked message, so the number reflects real in-flight work, not a hoarded backlog.
Cause 4: a slow or hung consumer
Sometimes the consumer isn't broken, just stuck - blocked on a slow database call, an external API with no timeout, or an infinite loop. It's holding unacked messages it hasn't finished. In the UI the queue's Unacked sits high and flat while Ready doesn't move.
Check whether the consumer is alive and doing anything:
docker compose logs -f worker
If it's frozen, the fix is in the consumer: add timeouts to external calls, and let a long-running worker be restartable. When the consumer disconnects, the broker returns its unacked messages to Ready and hands them to another consumer - which is the whole safety value of manual acks.
How to spot which cause it is
The UI tells you a lot at a glance:
- Unacked grows, Ready shrinks, worker logs are silent - forgotten ack or an exception before the ack (causes 1 and 2).
- Unacked is large and flat, one consumer, others idle - prefetch too high (cause 3).
- Unacked is stuck at a fixed number, worker is up but doing nothing - hung consumer (cause 4).
Then click the queue and look at Consumers: if a consumer holds many unacked messages and its ack rate is zero, you've found it.
FAQ
Will unacked messages ever get lost?
No. That's the point of manual acks. An unacked message is safe - if the consumer disconnects or crashes without acking, the broker requeues it and redelivers it. The risk is duplication (the work might run twice), not loss.
Why do unacked messages block my other consumers?
Because they're already reserved for the consumer that holds them. The broker won't give a delivered-but-unacked message to anyone else until it's acked or the holder disconnects. A high prefetch on one slow consumer can starve the others - lower the prefetch.
I'm using Laravel and see unacked piling up. What's wrong?
Usually a job that hangs or runs very long, so the worker holds it unacked the whole time.
Give jobs a --timeout, keep them short, and make sure the worker isn't blocked on an
external call without a timeout of its own.