Skip to content

_


Message Queues

Understanding message queues. How they decouple producers and consumers, handle failures, and enable scalable distributed systems.

The Problem

You already know how a load balancer distributes requests across servers. But what happens when those servers need to call slower downstream services. Generate a PDF, send an email, resize an image, or write to a slow database? The server is stuck waiting. New requests pile up at the load balancer, queues overflow, timeouts cascade, and the whole system collapses. The load balancer can spread the traffic, but it cannot absorb a burst or decouple a fast producer from a slow consumer.

Request Routed Processing 408 — Timeout 503 — Rejected

The Solution: Message Queues

A message queue sits between the service that produces work and the workers that process it. Instead of the server calling the slow service directly and blocking, it drops a message into a queue managed by a broker like RabbitMQ and responds immediately to the client. Workers pick messages from the queue at their own pace. If traffic spikes, the queue absorbs the burst. If a worker crashes, the message is not lost. Another worker picks it up. The load balancer handles request distribution; the message queue handles work decoupling. They solve different problems and work together.

Message Routed Processing TTL — Expired nack — Rejected

Key Concepts

Before diving into exchange types, here are the core concepts of a message queue system:

  • Producer: the application that sends messages. It does not send directly to a queue, but to an exchange.
  • Consumer (Worker): the application that receives and processes messages from a queue. A queue can have multiple consumers competing for messages.
  • Broker: the server that receives messages from producers and routes them to queues. RabbitMQ is the broker in these visualizations.
  • Exchange: the routing component inside the broker. Every message goes through an exchange first. The exchange examines the routing key and its bindings to decide which queue(s) should receive the message. RabbitMQ has four built-in types: direct, fanout, topic, and headers. The "work queue" pattern covered below uses the default (unnamed) direct exchange.
  • Queue: a buffer that stores messages until a consumer processes them. Messages wait in FIFO order (first in, first out).
  • Routing Key: a label attached to each message by the producer. The exchange uses this key to decide where to route the message. For example, payment.completed.us.
  • Binding: a rule that links an exchange to a queue. It can include a pattern (in topic exchanges) or an exact key (in direct exchanges).
  • Ack (Acknowledgment): a signal sent by the consumer to the broker confirming that a message was successfully processed. Only after receiving an ack does the broker remove the message from the queue.
  • Nack (Negative Acknowledgment): a signal sent by the consumer when processing fails. The broker can then requeue the message for another attempt or route it to a Dead Letter Queue.
  • Dead Letter Queue (DLQ): a special queue where messages are sent after exhausting all retries or exceeding their TTL. It acts as a safety net so no message is silently lost.
  • TTL (Time-to-Live): the maximum time a message can remain in a queue before being discarded or moved to a DLQ.
  • Prefetch Count: the maximum number of unacknowledged messages a consumer can receive at once. With prefetch_count=1, RabbitMQ only delivers a new message after the consumer acknowledges the previous one. This enables fair dispatch in work queues, ensuring slow consumers do not get overloaded.
  • Headers Exchange: routes messages based on header attributes instead of the routing key. Multiple headers can be matched using x-match=all (every header must match) or x-match=any (at least one). It is the most flexible but least common exchange type.

Exchange Types

Work Queue

The simplest pattern. Like a bakery counter with one ticket line and multiple cashiers: each customer (message) is served by exactly one cashier (worker). Messages go to a single queue and RabbitMQ distributes them round-robin across connected workers. With prefetch_count=1, each worker only gets a new message after acknowledging the previous one, so slow workers naturally receive less work.

Simple to set up, natural load balancing across workers, no message duplication. Adding more workers instantly increases throughput

No routing flexibility. All messages go to the same queue. If you need different types of work processed by specialized workers, you need a different exchange type

Use case: Background job processing. Image resizing, PDF generation, email sending. Instagram uses work queues for processing uploaded photos: each image goes through a pipeline of resize, filter, and upload tasks distributed across hundreds of workers.

RabbitMQ (Python/pika):
channel.queue_declare(queue='tasks', durable=True)
channel.basic_qos(prefetch_count=1)

# Producer
channel.basic_publish(
    exchange='',
    routing_key='tasks',
    body=message,
    properties=pika.BasicProperties(
        delivery_mode=2  # persistent
    )
)

# Consumer
channel.basic_consume(
    queue='tasks',
    on_message_callback=callback
)
Message Routed Processing TTL — Expired nack — Rejected

Direct Exchange

Like a post office sorting facility. Each letter has a destination code (routing key), and the sorting machine sends it to the exact bin (queue) that matches. A message with routing_key="error" only goes to queues bound with that exact key. Multiple queues can bind to the same key, and one queue can bind to multiple keys.

Precise routing. Each message goes exactly where it needs to. Workers only receive messages they can handle. Clean separation of concerns

Routing keys must match exactly. No pattern matching or wildcards. Every new routing scenario requires explicit bindings. Does not scale well when the number of routing keys grows dynamically

Use case: Log processing by severity. Error logs go to the alerting service, info logs go to the analytics pipeline, debug logs go to cold storage. Robinhood uses direct exchanges to route trade execution messages to the correct clearing house based on the exchange code.

RabbitMQ (Python/pika):
channel.exchange_declare(
    exchange='direct_logs',
    exchange_type='direct'
)

# Producer: route by severity
channel.basic_publish(
    exchange='direct_logs',
    routing_key='error',
    body=message
)

# Consumer: bind to specific keys
channel.queue_bind(
    exchange='direct_logs',
    queue=queue_name,
    routing_key='error'
)
error warn info Processing TTL — Expired nack — Rejected

Fanout Exchange

Like a radio broadcast. The station (exchange) transmits one signal and every tuned-in radio (queue) receives it. A fanout exchange ignores the routing key entirely and delivers a copy of every message to every bound queue. If you have 5 queues bound, each message becomes 5 independent copies.

Every consumer gets every message. Perfect for broadcasting events. Adding new consumers requires zero changes to the producer. Completely decoupled

No filtering. Every queue gets everything, which wastes bandwidth and processing if consumers only need a subset. Message volume multiplies with each new queue

Use case: Real-time notifications. When a user posts a new photo, the event is fanned out to the notification service, the news feed service, the analytics service, and the search indexer simultaneously. Facebook uses fanout for news feed distribution, where a single post must reach thousands of followers.

RabbitMQ (Python/pika):
channel.exchange_declare(
    exchange='events',
    exchange_type='fanout'
)

# Producer: routing_key is ignored
channel.basic_publish(
    exchange='events',
    routing_key='',
    body=message
)

# Each consumer binds its own queue
channel.queue_bind(
    exchange='events',
    queue=queue_name
)
Message Routed Processing TTL — Expired nack — Rejected

Topic Exchange

Like a newspaper subscription where you pick sections. You subscribe to "sports.*" and get everything about sports, or "*.breaking" and get all breaking news regardless of section. Topic exchanges route messages based on wildcard pattern matching on the routing key. The key must be a dot-separated list of words. * matches one word, # matches zero or more.

Flexible routing with pattern matching. Consumers subscribe only to what they need. One exchange can serve many different routing scenarios. Clean evolution as new message types appear

More complex than direct routing. Patterns can overlap in surprising ways. Performance decreases with very large numbers of bindings. Debugging message routing requires understanding all active patterns

Use case: Microservice event bus. A payment service publishes to "payment.completed.us" and "payment.failed.eu". The fraud service subscribes to "payment.*.eu", the analytics service to "payment.#", and the US tax service to "payment.completed.us". Uber uses topic-based routing for ride events across geographic regions and event types.

RabbitMQ (Python/pika):
channel.exchange_declare(
    exchange='topic_logs',
    exchange_type='topic'
)

# Producer: dotted routing key
channel.basic_publish(
    exchange='topic_logs',
    routing_key='kern.error',
    body=message
)

# Consumer: wildcards
channel.queue_bind(
    exchange='topic_logs',
    queue=queue_name,
    routing_key='*.error'  # or 'kern.#'
)
kern.error kern.info app.error app.info Processing TTL — Expired nack — Rejected

Priority Queue

RabbitMQ supports priority queues where messages with higher priority are delivered before lower-priority ones. When a high-priority message arrives, it jumps ahead of normal messages waiting in the queue. This is useful for urgent tasks. Like processing a payment before resizing a thumbnail. Click "Send Priority" to inject a high-priority message (shown in gold) and watch it skip ahead in the queue.

Message Routed Processing TTL — Expired nack — Rejected

Back Pressure

What happens when the queue itself fills up? RabbitMQ can enforce a maximum queue length. When the limit is reached, new messages are either dropped or the oldest messages are pushed to a dead letter exchange. Producers can also be blocked by connection-level flow control, giving workers time to catch up. This prevents unbounded memory growth and keeps the broker stable under heavy load.

Message Routed Processing TTL — Expired nack — Rejected

Real-World Tools

Message queues power some of the largest systems in the world. Here are the most widely used implementations, each with different trade-offs.

RabbitMQ

The most popular open-source message broker. Supports AMQP protocol, multiple exchange types (direct, fanout, topic), and flexible routing. Used by companies like Bloomberg, Robinhood, and Instagram for task queues and event-driven architectures.

Apache Kafka

A distributed event streaming platform designed for high throughput. Unlike traditional queues, Kafka retains messages in a log and allows multiple consumers to read at their own pace. Used by LinkedIn, Netflix, and Uber for real-time data pipelines processing millions of events per second.

Amazon SQS

Amazon's fully managed queue service. Offers Standard (at-least-once, best-effort ordering) and FIFO (exactly-once, strict ordering) variants. Zero operational overhead. No servers to manage. Used by companies like Capital One, BMW, and Airbnb for decoupling microservices.

Redis (Streams/Lists)

While primarily an in-memory data store, Redis Streams and Lists are widely used as lightweight queues. Extremely fast (sub-millisecond latency), ideal for real-time applications. Used by Twitter, GitHub, and Snapchat for job queues and pub/sub messaging.

Interactive Playground

Experiment with different exchange types, worker counts, production rates, failure rates, and queue limits. Watch how RabbitMQ routes messages to workers in real time and observe how each configuration affects throughput and reliability.

Exchange type
Message Routed Processing TTL — Expired nack — Rejected
msg/s
s
s
Processing time per worker
W1 0.0 1.5
W2 0.0 1.5
W3 0.0 1.5
RabbitMQ (Python/pika):
import pika

connection = pika.BlockingConnection(
    pika.ConnectionParameters('localhost')
)
channel = connection.channel()

channel.queue_declare(queue='tasks', durable=True)

# Producer
channel.basic_publish(
    exchange='',
    routing_key='tasks',
    body=message,
    properties=pika.BasicProperties(
        delivery_mode=2  # persistent
    )
)

# Consumer
channel.basic_qos(prefetch_count=1)
channel.basic_consume(
    queue='tasks',
    on_message_callback=callback
)

03/23/2026 — lucasquin.dev