Skip to content

Message Queues Explained: Async Work, Delivery, and Failure Modes

Aman Kumar Singh6 min read
Part 8 of 12From the System Design Fundamentals series
Message Queues Explained: Async Work, Delivery, and Failure Modes — article by Aman Kumar Singh

The single most useful architectural move in a growing backend is learning to say "not right now." A message queue is how you say it. Instead of doing every piece of work inside the request that triggered it, you hand the work to a queue and return immediately, letting something else process it in the background. That one shift — from synchronous to asynchronous — unlocks resilience, throughput, and decoupling that are hard to get any other way.

This explains what queues are, the delivery guarantees that decide how much you can trust them, and the failure modes you have to design around. It builds on concrete implementations like BullMQ background jobs, Redis lists as queues, and event-driven NestJS.

Why put work behind a queue

Consider a user signing up. You need to create their account, send a welcome email, provision some defaults, maybe notify an analytics system. Doing all of that inside the signup request means the user waits for every step, and if the email service is slow or down, their signup fails for a reason that has nothing to do with signing up.

Put the non-essential work behind a queue and the shape changes entirely. The request creates the account, drops a "user signed up" message on the queue, and returns. The user gets an instant response. Separate workers pick up the message and handle the email and the rest on their own time. If the email service is down, the message waits and retries; the signup already succeeded. You have decoupled the thing that must happen now from the things that merely need to happen eventually.

Three benefits fall out of this:

  • Responsiveness. Requests return as soon as the essential work is done, not after every side effect.
  • Resilience. A slow or failed downstream service delays the queued work instead of breaking the user-facing request. The queue absorbs the outage.
  • Load smoothing. A traffic spike fills the queue, and workers drain it at their own sustainable pace. The queue acts as a buffer so a burst does not overwhelm the systems downstream — a form of built-in backpressure.

Queues versus streams

"Message queue" is often used loosely for two related but different tools, and the difference matters.

A queue (think RabbitMQ, or Redis lists, or SQS) is about distributing work. A message is put on the queue, one worker picks it up, processes it, and the message is gone. The point is to hand out tasks to a pool of workers so they get done once. If you have ten workers and a thousand jobs, the jobs are spread across the workers and each job is handled by exactly one of them.

A stream or log (think Kafka, or Redis streams) is about distributing events. Messages are appended to a durable log and stay there. Multiple independent consumers can each read the whole log at their own pace, and each tracks its own position. The same event can be processed by the billing system, the analytics system, and the notification system independently, because reading it does not consume it. Streams are how you fan a single event out to many interested parties without coupling them.

The rough guide: use a queue when you want work done once by one of many workers, and a stream when you want an event observed by many independent consumers, or when you need to replay history.

Delivery guarantees

This is the part people skip and later regret. How hard does the system try to deliver each message, and what does it promise?

At-most-once delivery means a message is delivered zero or one times. It might be lost, but it will never be duplicated. This is the weakest guarantee, suitable only for data where losing a message is acceptable, like a metric sample among thousands.

At-least-once delivery means a message will be delivered, but possibly more than once. If a worker processes a message but crashes before acknowledging it, the queue assumes it was not handled and redelivers it. Nothing is lost, but duplicates happen. This is the guarantee most real queues provide, because losing messages is usually worse than occasionally repeating them.

Exactly-once is what everyone wants and what no distributed system truly delivers at the transport level. What you actually get is "at-least-once delivery plus idempotent processing," which produces the effect of exactly-once. Because at-least-once means duplicates are inevitable, you make your message handlers idempotent — safe to run twice with the same result — and the duplicates stop mattering. This is the single most important thing to internalize about queues: assume every message can arrive more than once, and design handlers that do not care.

The failure modes to design around

Queues introduce their own problems, and mature systems plan for them.

Duplicates, as above, are not an edge case but a guarantee under at-least-once delivery. Idempotent handlers are the answer.

Ordering is not free. Many queues do not guarantee that messages are processed in the order they were sent, especially once you have multiple workers pulling concurrently. If order matters — apply "account created" before "account updated" — you either need a queue that supports ordering (often by partitioning related messages to the same consumer, the way consistent hashing routes related keys together) or you design handlers that tolerate out-of-order arrival.

Poison messages are messages that fail every time they are processed, perhaps because they are malformed. Under at-least-once retry, a poison message retries forever, blocking the queue and wasting resources. The standard defense is a dead-letter queue: after a message fails a set number of times, move it aside to a separate queue for inspection rather than retrying it endlessly. This keeps one bad message from stalling everything behind it.

Backpressure and growth. If messages arrive faster than workers process them, the queue grows without bound, and a queue that grows forever eventually becomes its own outage. Monitor queue depth as a first-class signal. A steadily growing queue is an early warning that your workers are under-provisioned, and it is far better to catch it on a graph than when the queue's storage fills up.

Where this fits

A queue is the seam between the synchronous world of user requests and the asynchronous world of everything that can happen later. It is one of the cleanest ways to make a system both faster and more resilient, and it is a near-universal ingredient in scalable architectures. But it is not free: you trade the simplicity of synchronous, ordered, exactly-once thinking for a world of duplicates, reordering, and eventual processing. Handle those honestly — idempotent handlers, dead-letter queues, and depth monitoring — and the queue becomes one of the most reliable tools in your system design toolkit rather than a new source of mysterious bugs.

Key takeaways

  • Moving non-essential work behind a queue makes requests responsive, absorbs downstream outages, and smooths traffic spikes.
  • A queue distributes work so each message is handled once by one worker; a stream/log keeps events so many consumers can each read all of them.
  • Most real queues provide at-least-once delivery, which means duplicates are guaranteed — plan for them rather than wishing them away.
  • "Exactly-once" at the transport level is impossible; what you build is at-least-once delivery plus idempotent handlers, which gives the same effect.
  • Design around the failure modes: idempotent handlers for duplicates, dead-letter queues for poison messages, and depth monitoring for backpressure.

Frequently asked questions

Why use a message queue?

To decouple work that must happen now from work that only needs to happen eventually. The request does the essential part and returns immediately; workers handle the rest in the background. This makes requests responsive, lets the system absorb a slow or failed downstream service, and buffers traffic spikes.

What is the difference between a queue and a stream?

A queue distributes work: a message is picked up by one worker, processed, and gone, so a pool of workers shares the tasks. A stream or log keeps messages durably so multiple independent consumers can each read the whole thing at their own pace and replay history. Use a queue for work done once, a stream for events observed by many.

Can a message queue guarantee exactly-once delivery?

Not at the transport level — the sender can never be sure a message arrived, so it must risk loss or risk duplicates. What you achieve is exactly-once processing: at-least-once delivery plus idempotent handlers, so duplicate deliveries collapse into a single effect, which is indistinguishable from exactly-once.

What is a dead-letter queue?

A separate queue where messages are moved after failing a set number of times. Without it, a "poison" message that fails every attempt retries forever under at-least-once delivery, blocking the queue. Dead-lettering sets it aside for inspection so one bad message does not stall everything behind it.

Related articles

API Gateways and Service Communication: REST, gRPC, and Events — Aman Kumar Singh
Horizontal vs Vertical Scaling: When to Scale Out, When to Scale Up — Aman Kumar Singh
Designing for High Availability: Redundancy, Failover, and RTO/RPO — Aman Kumar Singh

Explore more on

Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.