Skip to content

Message Queue Patterns — RabbitMQ, Kafka, Pub/Sub, Work Queues

DodaTech Updated 2026-06-22 11 min read

In this tutorial, you'll learn about Message Queue Patterns. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.

Message Queues enable asynchronous communication between distributed services by temporarily storing messages until consumers are ready to Process them, decoupling producers and consumers for resilience and scalability.

What You'll Learn

By the end of this tutorial, you will understand message Queue architecture, implement pub/sub and work Queue patterns with RabbitMQ, use Kafka for event streaming and log compaction, configure competing consumers for parallel processing, and choose the right delivery guarantee for your use case.

Why It Matters

Message Queues prevent data loss when consumers are down, smooth traffic spikes by buffering requests, and enable independent scaling of producers and consumers. Doda Browser uses Message Queues to decouple file upload from malware analysis, ensuring no file is lost even when the analysis cluster is under load.

Real-World Use

An image processing platform accepts uploads from millions of users. When a user uploads an image, a message is queued. Multiple worker processes consume messages from the Queue, generate thumbnails, and store results. If all workers are busy, messages wait in the Queue — no uploads are rejected.

Pub/Sub Pattern

flowchart LR
    P[Publisher] --> EX[Exchange / Topic]
    EX --> Q1[Queue: Email Service]
    EX --> Q2[Queue: Analytics Service]
    EX --> Q3[Queue: Notification Service]
    Q1 --> C1[Email Consumer]
    Q2 --> C2[Analytics Consumer]
    Q3 --> C3[Notification Consumer]
    style EX fill:#f90,color:#fff

Work Queue Pattern (Competing Consumers)

// work-queue.js
// Work queue with competing consumers using RabbitMQ (amqplib)

const amqp = require('amqplib');

async function setupWorkQueue() {
  const connection = await amqp.connect('amqp://localhost');
  const channel = await connection.createChannel();

  // Durable queue survives broker restarts
  const queue = 'image_tasks';
  await channel.assertQueue(queue, { durable: true });

  // Fair dispatch: don't send more than 1 message at a time per consumer
  channel.prefetch(1);

  console.log('[Work Queue] Waiting for tasks. Press Ctrl+C to exit.');

  channel.consume(queue, (msg) => {
    if (msg === null) return;

    const task = JSON.parse(msg.content.toString());
    console.log(`[Consumer] Processing task: ${task.id} (${task.type})`);

    // Simulate processing time based on task complexity
    const processingTime = task.complexity * 1000;
    setTimeout(() => {
      console.log(`[Consumer] Completed task: ${task.id} in ${processingTime}ms`);
      channel.ack(msg);  // Acknowledge after successful processing
    }, processingTime);
  }, { noAck: false });
}

setupWorkQueue().catch(console.error);

// ── Publisher ──
async function publishTasks() {
  const connection = await amqp.connect('amqp://localhost');
  const channel = await connection.createChannel();
  const queue = 'image_tasks';
  await channel.assertQueue(queue, { durable: true });

  const tasks = [
    { id: 'T1', type: 'thumbnail', complexity: 2 },
    { id: 'T2', type: 'watermark', complexity: 5 },
    { id: 'T3', type: 'compress', complexity: 1 },
    { id: 'T4', type: 'thumbnail', complexity: 3 },
    { id: 'T5', type: 'exif_clean', complexity: 4 },
  ];

  for (const task of tasks) {
    channel.sendToQueue(queue, Buffer.from(JSON.stringify(task)), {
      persistent: true,  // Message survives broker restart
    });
    console.log(`[Publisher] Sent task: ${task.id}`);
  }

  await channel.close();
  await connection.close();
}

publishTasks().catch(console.error);

Expected output (publisher):

[Publisher] Sent task: T1
[Publisher] Sent task: T2
[Publisher] Sent task: T3
[Publisher] Sent task: T4
[Publisher] Sent task: T5

Expected output (multiple consumers):

[Consumer A] Processing task: T1 (thumbnail) — 2s
[Consumer B] Processing task: T2 (watermark) — 5s
[Consumer A] Completed task: T1 in 2000ms
[Consumer A] Processing task: T3 (compress) — 1s
[Consumer A] Completed task: T3 in 1000ms
[Consumer A] Processing task: T4 (thumbnail) — 3s
[Consumer B] Completed task: T2 in 5000ms
[Consumer B] Processing task: T5 (exif_clean) — 4s

Work queues distribute tasks across multiple consumers. Each message is delivered to exactly one consumer. If a consumer crashes, its unacknowledged message is redelivered to another consumer.

Kafka Event Streaming

# kafka_streaming.py
# Kafka producer and consumer for event streaming

from kafka import KafkaProducer, KafkaConsumer
from kafka.errors import KafkaError
import json
import time

# ── Kafka Producer ──

class OrderEventProducer:
    """Producer for order events in Kafka."""

    def __init__(self, bootstrap_servers='localhost:9092'):
        self.producer = KafkaProducer(
            bootstrap_servers=bootstrap_servers,
            value_serializer=lambda v: json.dumps(v).encode('utf-8'),
            acks='all',  # Wait for all replicas to acknowledge
            retries=3,
            linger_ms=10,  # Batch small messages for 10ms
        )
        self.topic = 'order_events'

    def publish_order_event(self, event_type, data):
        """Publish an order event to Kafka."""
        event = {
            'event_type': event_type,
            'timestamp': time.time(),
            'data': data,
        }

        # Use order_id as partition key for ordering guarantees
        order_id = data.get('order_id', 'unknown')
        future = self.producer.send(
            self.topic,
            key=order_id.encode('utf-8'),
            value=event
        )

        try:
            record_metadata = future.get(timeout=10)
            print(f"[Kafka] Published {event_type} for {order_id} "
                  f"-> partition {record_metadata.partition} "
                  f"offset {record_metadata.offset}")
        except KafkaError as e:
            print(f"[Kafka] Failed to publish: {e}")

    def close(self):
        self.producer.flush()
        self.producer.close()

producer = OrderEventProducer()
producer.publish_order_event('order_created', {
    'order_id': 'ORD-789',
    'user_id': 42,
    'amount': 199.99,
    'items': 3,
})
producer.publish_order_event('payment_processed', {
    'order_id': 'ORD-789',
    'status': 'success',
    'transaction_id': 'txn_abc123',
})
producer.close()

Expected output:

[Kafka] Published order_created for ORD-789 -> partition 0 offset 1
[Kafka] Published payment_processed for ORD-789 -> partition 0 offset 2

Events with the same key Go to the same partition, preserving order for that order_id.

# kafka_consumer.py
# Kafka consumer with consumer groups

from kafka import KafkaConsumer, TopicPartition
import json

class OrderEventConsumer:
    """Consumer for order events with offset management."""

    def __init__(self, group_id='order-processor', bootstrap_servers='localhost:9092'):
        self.consumer = KafkaConsumer(
            'order_events',
            bootstrap_servers=bootstrap_servers,
            group_id=group_id,
            auto_offset_reset='earliest',
            enable_auto_commit=True,
            auto_commit_interval_ms=5000,
            value_deserializer=lambda v: json.loads(v.decode('utf-8')),
        )
        print(f"[Kafka Consumer] Started group={group_id}")

    def process_events(self):
        """Consume and process order events."""
        for message in self.consumer:
            event = message.value
            print(f"[Consumer] Event: {event['event_type']} "
                  f"(partition={message.partition}, "
                  f"offset={message.offset})")

            # Process based on event type
            if event['event_type'] == 'order_created':
                self.handle_order_created(event['data'])
            elif event['event_type'] == 'payment_processed':
                self.handle_payment_processed(event['data'])

    def handle_order_created(self, data):
        print(f"  -> Order {data['order_id']}: ${data['amount']}, {data['items']} items")

    def handle_payment_processed(self, data):
        print(f"  -> Payment {data['transaction_id']}: {data['status']}")

if __name__ == '__main__':
    consumer = OrderEventConsumer()
    consumer.process_events()

Expected output:

[Kafka Consumer] Started group=order-processor
[Consumer] Event: order_created (partition=0, offset=1)
  -> Order ORD-789: $199.99, 3 items
[Consumer] Event: payment_processed (partition=0, offset=2)
  -> Payment txn_abc123: success

Kafka consumers in the same consumer group share partitions. If one consumer fails, the group rebalances and another consumer takes over its partitions, ensuring continuous processing.

Message Routing with RabbitMQ Topic Exchanges

# topic_routing.py
# RabbitMQ topic exchange for selective message routing

import pika
import JSON

class TopicRouter:
    """Route messages based on routing key patterns."""

    def __init__(self):
        self.connection = pika.BlockingConnection(
            pika.ConnectionParameters('localhost', 5672)
        )
        self.channel = self.connection.channel()
        self.channel.exchange_declare(
            exchange='system_events',
            exchange_type='topic',
            durable=True
        )

    def publish(self, routing_key, data):
        """Publish message with a routing key (e.g., 'error.auth.login')."""
        message = {
            'severity': routing_key.split('.')[0],
            'source': '.'.join(routing_key.split('.')[1:]),
            'data': data,
            'timestamp': __import__('time').time(),
        }
        self.channel.basic_publish(
            exchange='system_events',
            routing_key=routing_key,
            body=JSON.dumps(message),
            properties=pika.BasicProperties(delivery_mode=2)
        )
        print(f"[Router] Published: {routing_key}")

    def setup_consumer(self, Queue_name, binding_key):
        """
        Bind a Queue with a pattern.
        * matches one word, # matches zero or more words.
        Example: 'error.#' matches 'error.auth.login'
        """
        result = self.channel.Queue_declare(Queue=Queue_name, durable=True)
        self.channel.Queue_bind(
            exchange='system_events',
            Queue=Queue_name,
            routing_key=binding_key
        )
        print(f"[Router] Queue '{Queue_name}' bound with '{binding_key}'")

        def callback(ch, method, properties, body):
            event = JSON.loads(body)
            print(f"[{Queue_name}] Received: {method.routing_key}{event['data']}")
            ch.basic_ack(delivery_tag=method.delivery_tag)

        self.channel.basic_consume(
            Queue=Queue_name,
            on_message_callback=callback
        )
        return Queue_name

router = TopicRouter()

# Set up consumers with different patterns
router.setup_consumer('all_errors', 'error.#')
router.setup_consumer('auth_events', '#.auth.#')
router.setup_consumer('critical_alerts', 'critical.*')

# Publish events
router.publish('error.auth.login', {'user': 'alice', 'reason': 'invalid_password'})
router.publish('error.db.timeout', {'query': 'SELECT * FROM orders'})
router.publish('info.auth.logout', {'user': 'bob'})
router.publish('critical.disk', {'usage': 97, 'device': '/dev/sda1'})

Expected output:

[Router] Queue 'all_errors' bound with 'error.#'
[Router] Queue 'auth_events' bound with '#.auth.#'
[Router] Queue 'critical_alerts' bound with 'critical.*'
[Router] Published: error.auth.login
[all_errors] Received: error.auth.login — {'user': 'alice', 'reason': 'invalid_password'}
[auth_events] Received: error.auth.login — {'user': 'alice', 'reason': 'invalid_password'}
[Router] Published: error.db.timeout
[all_errors] Received: error.db.timeout — {'query': 'SELECT * FROM orders'}
[Router] Published: info.auth.logout
[auth_events] Received: info.auth.logout — {'user': 'bob'}
[Router] Published: critical.disk
[all_errors] Received: critical.disk — {'usage': 97, 'device': '/dev/sda1'}
[critical_alerts] Received: critical.disk — {'usage': 97, 'device': '/dev/sda1'}

Topic exchanges use routing key patterns to selectively deliver messages. error.# catches all error events. #.auth.# catches any event with auth in the source path. critical.* catches critical events one level deep.

Common Errors

1. Not Handling Poison Messages

A message that causes a consumer to crash repeatedly (poison message) stays in the Queue and gets redelivered infinitely, blocking other messages behind it. Implement a dead letter Queue (DLQ) with a max retry count — after N failures, the message moves to the DLQ for manual inspection.

2. Ignoring Message Ordering

Multiple consumers Process messages simultaneously. If order matters (e.g., "create user" before "send welcome email"), use partitioned topics (Kafka) or a single active consumer (RabbitMQ single active consumer). Do not assume message order with competing consumers.

3. Creating Too Many Queues/Partitions

Each Queue and partition adds overhead for the broker. RabbitMQ can handle thousands of queues but performance degrades beyond that. Kafka partitions are limited by file handles and Replication overhead. Design your topology with future scale in mind.

4. Not Setting Consumer Timeouts

If a consumer holds a message without acknowledging it (stuck processing, infinite loop), the message stays unacknowledged and is never redelivered. Set consumer timeouts in RabbitMQ (e.g., consumer_timeout: 1800000 for 30 minutes) so stuck consumers are disconnected and messages are requeued.

5. Choosing Wrong Delivery Semantics

At-most-once delivery loses messages if the consumer crashes before processing. At-least-once delivery can duplicate messages if the consumer acknowledges after processing but crashes before the ack reaches the broker. Exactly-once delivery (Kafka Transactions) adds latency. Match the guarantee to your use case — payment processing needs at-least-once; analytics can tolerate at-most-once.

6. Skipping Monitoring and Alerting

Message Queue issues are silent: messages accumulate in queues, consumers fall behind, and the problem is only noticed when processing delays become critical. Monitor Queue depth, consumer lag, unacknowledged message count, and dead letter Queue size. Set alerts for anomalies.

Practice Questions

1. What is the difference between a pub/sub and a work Queue pattern?

Pub/sub delivers each message to all subscribed consumers (broadcast). A work Queue delivers each message to exactly one consumer (competing consumers). Pub/sub is for event notifications; work queues are for distributing tasks across workers.

2. How does Kafka achieve high throughput compared to RabbitMQ?

Kafka uses sequential disk I/O, batching, zero-copy optimization, and a partitioned log model. RabbitMQ is optimized for complex routing with exchanges and immediate delivery. Kafka is better for high-throughput event streaming; RabbitMQ is better for smart routing and task queues.

3. What is a dead letter Queue and when would you use one?

A dead letter Queue stores messages that cannot be processed successfully. Use it for messages that exceed the retry limit, have invalid formats, or cause recurring consumer errors. A human operator inspects the DLQ, fixes the issue, and replays messages.

4. How do consumer groups work in Kafka?

Consumers in the same group share the workload — each partition is assigned to exactly one consumer in the group. If a consumer fails, partitions are reassigned. More consumers than partitions means some consumers are idle. This enables horizontal scaling of consumption.

Challenge

Design a message Queue topology for a video processing platform: (1) uploads are published to a work Queue with competing consumers for transcoding, (2) transcoded videos publish events to a pub/sub exchange for downstream services (thumbnail generation, analytics, notification), (3) failed transcodes Go to a dead letter Queue with a max of 3 retries, (4) watermark and subtitle generation consumes from separate queues with priority over transcoding, (5) Kafka streams aggregate processing metrics in real time, and (6) a monitoring consumer tracks Queue depth and consumer lag for all queues.

Mini Project: Multi-Service Event Bus

// event-bus-amqp.js
// RabbitMQ-based event bus for Microservices

const amqp = require('amqplib');

class EventBus {
  constructor(URL = 'amqp://localhost') {
    this.URL = URL;
    this.connection = null;
    this.channel = null;
    this.exchange = 'app_events';
  }

  async connect() {
    this.connection = await amqp.connect(this.URL);
    this.channel = await this.connection.createChannel();
    await this.channel.assertExchange(this.exchange, 'topic', { durable: true });
    console.log('[EventBus] Connected to RabbitMQ');
    return this;
  }

  async publish(eventType, data, routingKey) {
    if (!this.channel) await this.connect();
    const message = {
      eventType,
      timestamp: new Date().toISOString(),
      data,
    };
    const key = routingKey || eventType.replace(/\./g, '.');
    this.channel.publish(
      this.exchange,
      key,
      Buffer.from(JSON.stringify(message)),
      { persistent: true }
    );
    console.log(`[EventBus] Published: ${eventType} (${key})`);
  }

  async subscribe(patterns, handler, queueName = '') {
    if (!this.channel) await this.connect();
    const q = await this.channel.assertQueue(queueName || '', {
      exclusive: !queueName,
      durable: !!queueName,
    });

    for (const pattern of patterns) {
      await this.channel.bindQueue(q.Queue, this.exchange, pattern);
      console.log(`[EventBus] Bound ${q.Queue} <- ${pattern}`);
    }

    await this.channel.consume(q.Queue, (msg) => {
      if (msg === null) return;
      const event = JSON.parse(msg.content.toString());
      handler(event, msg);
      this.channel.ack(msg);
    });

    return q.Queue;
  }

  async close() {
    await this.channel?.close();
    await this.connection?.close();
  }
}

async function demo() {
  const bus = new EventBus();
  await bus.connect();

  // Subscribe to all user events
  await bus.subscribe(['user.#'], (event) => {
    console.log(`[User Service] ${event.eventType}: ${event.data.email}`);
  });

  // Subscribe to payment events only
  await bus.subscribe(['payment.completed'], (event) => {
    console.log(`[Payment Service] Completed: ${event.data.amount}`);
  });

  // Publish events
  await bus.publish('user.registered', { email: 'alice@example.com', plan: 'premium' });
  await bus.publish('payment.completed', { userId: 1, amount: 29.99 });
  await bus.publish('user.upgraded', { email: 'alice@example.com', newPlan: 'enterprise' });

  setTimeout(() => bus.close(), 1000);
}

demo().catch(console.error);

Expected output:

[EventBus] Connected to RabbitMQ
[EventBus] Bound amq.gen-abc <- user.#
[EventBus] Bound amq.gen-def <- payment.completed
[EventBus] Published: user.registered (user.registered)
[User Service] user.registered: alice@example.com
[EventBus] Published: payment.completed (payment.completed)
[Payment Service] Completed: 29.99
[EventBus] Published: user.upgraded (user.upgraded)
[User Service] user.upgraded: alice@example.com

Congratulations on completing this message Queue patterns tutorial! Next, explore Microservices communication patterns for service-to-service messaging, then learn about caching strategies for queuing with cache layers.

  • Practice daily — Set up RabbitMQ with Docker and experiment with all four exchange types
  • Build a project — Build a distributed task processing system with a work Queue, dead letter Queue, and monitoring dashboard
  • Explore related topics — Check out Apache Pulsar, NATS, and Amazon SQS/SNS for alternative messaging solutions

Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro