System Design

Message Queues Explained

Learn how message queues work in backend systems, including producers, consumers, retries, dead-letter queues, ordering, and idempotency.

Diagram showing a producer, queue, workers, retry path, and dead-letter queue.

Quick answer

A message queue lets one part of a system send work to another part asynchronously. A producer publishes a message, the queue stores it, and a consumer processes it later.

Queues help backend systems smooth traffic spikes, decouple services, retry failed work, and move slow tasks out of user-facing request paths.

Why queues matter

Without a queue, a request may need to do everything before returning:

create order -> charge payment -> send email -> update analytics -> call warehouse

If every step happens inline, the user waits longer and the request is more likely to fail because one dependency is slow.

With a queue, the API can perform the required synchronous work, enqueue follow-up tasks, and return sooner:

create order -> enqueue email job -> enqueue warehouse sync -> return response

The background consumers handle the rest.

Producers, queues, and consumers

The basic parts are:

PartRole
ProducerPublishes messages to the queue.
QueueStores messages until consumers can process them.
ConsumerReads messages and performs the work.
AcknowledgmentTells the queue a message was processed successfully.

A message is usually a small JSON payload or event shape:

{
  "type": "order.created",
  "orderId": "ord_123",
  "accountId": "acct_42",
  "createdAt": "2026-06-29T12:00:00Z"
}

The message should include stable identifiers, not a huge copy of every related object.

At-least-once delivery

Many queues provide at-least-once delivery. That means a message should be delivered, but it may be delivered more than once.

Duplicates can happen when:

  • A consumer finishes work but crashes before acknowledging.
  • The queue visibility timeout expires.
  • The producer retries publishing after an uncertain failure.
  • A network issue hides the result of an acknowledgment.

Because duplicates are normal, consumers should be idempotent. Read Idempotency in APIs Explained for the broader retry-safety pattern.

Retries

Queues are useful because failed work can be retried.

A consumer might fail because:

  • A downstream API timed out.
  • The database was temporarily unavailable.
  • The message payload was invalid.
  • A bug caused an exception.

Transient failures can be retried with backoff. Permanent failures should not be retried forever.

attempt 1 -> fail -> wait 10 seconds
attempt 2 -> fail -> wait 60 seconds
attempt 3 -> fail -> send to dead-letter queue

Backoff prevents a broken dependency from being hammered by immediate retries.

Dead-letter queues

A dead-letter queue stores messages that could not be processed after the retry policy.

Dead-letter queues help teams inspect failures without losing the message. They are also useful for alerting, manual repair, and replay after a bug is fixed.

A message in a dead-letter queue should include enough metadata to debug it:

  • Original message payload.
  • Error type or reason.
  • Attempt count.
  • First failure time.
  • Last failure time.
  • Consumer name or version.

Do not treat a dead-letter queue as a place messages can disappear forever. It needs monitoring.

Ordering

Message ordering is more subtle than it looks.

Some queues guarantee ordering only within a partition, group, or shard. Others make no strong ordering guarantee. Even when a queue preserves order, retries can still make processing appear out of order.

If ordering matters, design for it explicitly:

  • Use a partition key such as account ID or order ID.
  • Store sequence numbers.
  • Make updates conditional on expected state.
  • Use optimistic locking for shared records.

Read Optimistic Locking Explained for one way to protect state transitions from stale updates.

Queue vs event stream

Queues and event streams are related but not identical.

PatternTypical use
QueueWork distribution where each message is processed by one consumer group.
Event streamDurable event log where multiple consumers may independently read events.

A queue is a good fit for jobs like sending emails or processing uploads. An event stream is a better fit for analytics pipelines, audit logs, or multiple downstream projections.

What to put in a message

Good messages are small, explicit, and versionable.

Include:

  • Event or job type.
  • Stable IDs.
  • Minimal context needed to process.
  • Creation timestamp.
  • Schema or version when useful.
  • Idempotency key or event ID.

Avoid including secrets, access tokens, or large object graphs. The consumer can fetch fresh data by ID when needed.

Common mistakes

The first mistake is assuming a queue means exactly-once processing. Most systems should be designed for duplicate delivery.

The second mistake is putting slow, failing work into a queue without monitoring. A queue can hide latency until the backlog becomes an incident.

The third mistake is retrying invalid messages forever. Bad payloads need validation and dead-letter handling.

The fourth mistake is ignoring consumer idempotency. A duplicate email, duplicate charge, or duplicate state transition is still a real bug.

Practical backend checklist

Before adding a queue:

  • Decide which work must stay synchronous.
  • Define the message schema and versioning plan.
  • Add stable event IDs or idempotency keys.
  • Make consumers safe for duplicate messages.
  • Configure retry and dead-letter behavior.
  • Monitor queue depth, age, failures, and retry rate.
  • Document replay behavior.

Read Idempotency in APIs Explained for duplicate-safe operations and Rate Limiting in APIs Explained for controlling retry pressure. For state conflicts during async processing, read Optimistic Locking Explained. Use the UUID Generator for local event ID and idempotency key examples.