MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr πŸ”₯ tagline

Hey there πŸ‘‹ I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 β€” present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 β€” Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms β€” earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Message Queues: RabbitMQ vs Kafka vs SQS

Compare message queues: throughput, durability, ordering, and use cases.

Message QueueRabbitMQKafkaArchitecture

By MinhVo

Introduction

Message queues are the backbone of distributed systems, enabling asynchronous communication between services, handling traffic spikes, and ensuring reliable message delivery. Choosing the right message queue for your application is one of the most consequential architectural decisions you'll make. The wrong choice can lead to data loss, performance bottlenecks, or operational complexity that overwhelms your team.

RabbitMQ, Apache Kafka, and Amazon SQS are the three most widely used message queuing systems, but they solve fundamentally different problems. RabbitMQ is a traditional message broker optimized for routing and delivery guarantees. Kafka is a distributed streaming platform designed for high-throughput event processing. SQS is a fully managed queue service that eliminates operational overhead. Understanding their differences isn't about which is "best"β€”it's about matching the right tool to your specific requirements.

Message queue architecture

Understanding Message Queues: Core Concepts

Message queues decouple producers from consumers. A producer sends a message to the queue without knowing or caring which consumer will process it. This decoupling provides several benefits: producers and consumers can scale independently, consumers can process messages at their own pace, and the system remains resilient to temporary failures.

The core concepts are consistent across implementations: producers publish messages, queues or topics store messages, and consumers subscribe to receive messages. However, the semantics differ significantly. RabbitMQ routes messages through exchanges to queues, supporting complex routing patterns. Kafka stores messages in append-only logs organized by topic and partition, enabling replay and parallel consumption. SQS provides a simple queue with at-least-once delivery and automatic scaling.

Delivery guarantees range from at-most-once (messages may be lost) to at-least-once (messages may be delivered multiple times) to exactly-once (messages are delivered exactly once). At-most-once is the simplest but risks data loss. At-least-once requires idempotent consumers. Exactly-once is the hardest to achieve and has performance implications. Understanding which guarantee your application needs is essential for choosing the right system.

Message ordering is another critical consideration. RabbitMQ guarantees FIFO ordering within a single queue. Kafka guarantees ordering within a partition but not across partitions. SQS offers FIFO queues with strict ordering but lower throughput, or standard queues with higher throughput but potential out-of-order delivery. Applications that depend on message ordering must choose accordingly.

Durability ensures messages survive system failures. RabbitMQ persists messages to disk when persistent delivery mode is used. Kafka replicates messages across brokers with configurable replication factors. SQS stores messages redundantly across multiple availability zones. All three provide durability, but the operational complexity of maintaining it differs.

Architecture and Design Patterns

RabbitMQ Architecture

RabbitMQ implements the AMQP (Advanced Message Queuing Protocol) model with exchanges, queues, and bindings. Producers send messages to exchanges, which route them to queues based on binding rules. Consumers subscribe to queues and acknowledge messages after processing.

import * as amqp from 'amqplib';
 
async function setupRabbitMQ() {
    const connection = await amqp.connect('amqp://localhost');
    const channel = await connection.createChannel();
 
    // Declare exchange
    await channel.assertExchange('orders', 'topic', { durable: true });
 
    // Declare queues
    await channel.assertQueue('orders.processing', { durable: true });
    await channel.assertQueue('orders.analytics', { durable: true });
 
    // Bind queues to exchange
    await channel.bindQueue('orders.processing', 'orders', 'order.created');
    await channel.bindQueue('orders.analytics', 'orders', 'order.*');
 
    // Publish message
    channel.publish('orders', 'order.created', Buffer.from(JSON.stringify({
        orderId: '12345',
        customerId: 'cust-789',
        items: [{ sku: 'ABC', qty: 2 }],
        timestamp: new Date().toISOString(),
    })), {
        persistent: true,
        contentType: 'application/json',
    });
 
    // Consume messages
    channel.consume('orders.processing', async (msg) => {
        if (!msg) return;
        const order = JSON.parse(msg.content.toString());
        await processOrder(order);
        channel.ack(msg);
    });
}

Kafka Architecture

Kafka organizes messages into topics, which are split into partitions. Each partition is an ordered, immutable sequence of records. Producers write to partitions, and consumers read from them using offsets. Consumer groups enable parallel processing across partitions.

import { Kafka, logLevel } from 'kafkajs';
 
const kafka = new Kafka({
    clientId: 'order-service',
    brokers: ['kafka-1:9092', 'kafka-2:9092', 'kafka-3:9092'],
    logLevel: logLevel.WARN,
});
 
async function setupKafka() {
    const producer = kafka.producer({
        allowAutoTopicCreation: true,
        transactionTimeout: 30000,
    });
    await producer.connect();
 
    // Produce messages with key for partition ordering
    await producer.send({
        topic: 'orders',
        messages: [
            {
                key: 'order-12345',  // Messages with same key go to same partition
                value: JSON.stringify({
                    orderId: '12345',
                    customerId: 'cust-789',
                    items: [{ sku: 'ABC', qty: 2 }],
                    timestamp: new Date().toISOString(),
                }),
                headers: {
                    'content-type': 'application/json',
                    'source': 'order-service',
                },
            },
        ],
    });
 
    // Consume with consumer group
    const consumer = kafka.consumer({ groupId: 'order-processor' });
    await consumer.connect();
    await consumer.subscribe({ topic: 'orders', fromBeginning: false });
 
    await consumer.run({
        eachMessage: async ({ topic, partition, message }) => {
            const order = JSON.parse(message.value!.toString());
            console.log(`Processing order ${order.orderId} from partition ${partition}`);
            await processOrder(order);
            // Kafka auto-commits offsets by default
        },
    });
}

SQS Architecture

SQS provides a fully managed queue with two types: Standard (best-effort ordering, at-least-once delivery) and FIFO (strict ordering, exactly-once processing).

import { SQSClient, SendMessageCommand, ReceiveMessageCommand, DeleteMessageCommand } from '@aws-sdk/client-sqs';
 
const sqs = new SQSClient({ region: 'us-east-1' });
 
async function sendToQueue(queueUrl: string, message: any) {
    await sqs.send(new SendMessageCommand({
        QueueUrl: queueUrl,
        MessageBody: JSON.stringify(message),
        MessageAttributes: {
            MessageType: { DataType: 'String', StringValue: 'OrderCreated' },
            Priority: { DataType: 'Number', StringValue: '1' },
        },
        // FIFO queue options
        // MessageGroupId: message.customerId,  // For FIFO queues
        // MessageDeduplicationId: message.orderId,
    }));
}
 
async function processQueue(queueUrl: string) {
    while (true) {
        const response = await sqs.send(new ReceiveMessageCommand({
            QueueUrl: queueUrl,
            MaxNumberOfMessages: 10,
            WaitTimeSeconds: 20,  // Long polling
            VisibilityTimeout: 30,
            MessageAttributeNames: ['All'],
        }));
 
        if (!response.Messages) continue;
 
        for (const msg of response.Messages) {
            try {
                const order = JSON.parse(msg.Body!);
                await processOrder(order);
 
                // Delete message after successful processing
                await sqs.send(new DeleteMessageCommand({
                    QueueUrl: queueUrl,
                    ReceiptHandle: msg.ReceiptHandle,
                }));
            } catch (error) {
                // Message returns to queue after visibility timeout
                console.error('Processing failed:', error);
            }
        }
    }
}

Step-by-Step Implementation

Setting Up RabbitMQ with Docker

# Run RabbitMQ with management plugin
docker run -d --name rabbitmq \
    -p 5672:5672 \
    -p 15672:15672 \
    -e RABBITMQ_DEFAULT_USER=admin \
    -e RABBITMQ_DEFAULT_PASS=password \
    rabbitmq:3-management
 
# Create exchange and queues via CLI
docker exec rabbitmq rabbitmqadmin declare exchange name=orders type=topic durable=true
docker exec rabbitmq rabbitmqadmin declare queue name=orders.processing durable=true
docker exec rabbitmq rabbitmqadmin declare queue name=orders.analytics durable=true
docker exec rabbitmq rabbitmqadmin declare binding source=orders destination=orders.processing routing_key="order.created"
docker exec rabbitmq rabbitmqadmin declare binding source=orders destination=orders.analytics routing_key="order.*"

Setting Up Kafka with Docker Compose

version: '3'
services:
  zookeeper:
    image: confluentinc/cp-zookeeper:7.5.0
    environment:
      ZOOKEEPER_CLIENT_PORT: 2181
      ZOOKEEPER_TICK_TIME: 2000
 
  kafka:
    image: confluentinc/cp-kafka:7.5.0
    depends_on:
    - zookeeper
    ports:
    - 9092:9092
    environment:
      KAFKA_BROKER_ID: 1
      KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
      KAFKA_AUTO_CREATE_TOPICS_ENABLE: true
 
  kafka-ui:
    image: provectuslabs/kafka-ui:latest
    ports:
    - 8080:8080
    environment:
      KAFKA_CLUSTERS_0_NAME: local
      KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:9092

Implementing a Reliable Consumer

Consumers must handle failures gracefully. Here's a pattern with dead letter queues and retry logic:

class ReliableConsumer {
    private retryCount = new Map<string, number>();
    private maxRetries = 3;
 
    async processMessage(message: any): Promise<void> {
        const messageId = message.id || message.messageId;
        const retries = this.retryCount.get(messageId) || 0;
 
        try {
            await this.handleMessage(message);
            this.retryCount.delete(messageId);
        } catch (error) {
            if (retries < this.maxRetries) {
                this.retryCount.set(messageId, retries + 1);
                // Requeue with delay
                await this.requeueWithDelay(message, (retries + 1) * 1000);
            } else {
                // Send to dead letter queue
                await this.sendToDLQ(message, error);
                this.retryCount.delete(messageId);
            }
        }
    }
 
    private async requeueWithDelay(message: any, delayMs: number) {
        // RabbitMQ: Use delayed message exchange
        // Kafka: Use a retry topic with consumer lag
        // SQS: Use delay queue or visibility timeout
        console.log(`Requeuing message ${message.id} with ${delayMs}ms delay`);
    }
 
    private async sendToDLQ(message: any, error: any) {
        console.error(`Message ${message.id} failed after ${this.maxRetries} retries:`, error);
        // Send to dead letter queue for manual inspection
    }
}

Message queue comparison

Real-World Use Cases and Case Studies

Use Case 1: Order Processing Pipeline

E-commerce order processing benefits from message queues at every step. When an order is placed, the API publishes an OrderCreated event. The payment service processes payment, the inventory service reserves stock, and the notification service sends confirmationsβ€”all consuming from the same queue independently. If the notification service is slow, it doesn't block order processing. If the payment service fails, the message remains in the queue for retry.

Use Case 2: Real-Time Analytics Pipeline

Kafka excels for real-time analytics where data needs to be processed by multiple consumers. User activity events flow into a Kafka topic, and separate consumer groups process them for different purposes: one group updates real-time dashboards, another feeds a recommendation engine, and a third populates a data warehouse. Each consumer group maintains its own offset, so they process independently without interfering with each other.

Use Case 3: Task Queue for Background Jobs

SQS works well for background job processing in AWS environments. Web requests publish tasks to SQS, and worker instances poll the queue for work. SQS handles scaling automaticallyβ€”when the queue depth increases, you can scale up workers with Auto Scaling. The visibility timeout ensures tasks are processed exactly once, and dead letter queues capture failed tasks for investigation.

Best Practices for Production

  1. Always use dead letter queues: Configure DLQs for every queue. Failed messages should be routed to a DLQ for investigation rather than being lost or infinitely retried.

  2. Implement idempotent consumers: With at-least-once delivery, consumers may receive duplicate messages. Design consumers to produce the same result regardless of how many times a message is processed.

  3. Use long polling for SQS: Set WaitTimeSeconds to 20 for SQS long polling. This reduces empty responses and API costs while improving latency.

  4. Set appropriate visibility timeouts: The visibility timeout should be long enough for your consumer to process the message. Too short causes duplicates; too long delays retry of failed messages.

  5. Monitor consumer lag: Track the difference between produced and consumed messages (consumer lag). Growing lag indicates consumers can't keep up with producers.

  6. Use message keys for ordering: In Kafka, messages with the same key go to the same partition, ensuring ordering for related messages (e.g., all events for the same customer).

  7. Batch messages for throughput: Both Kafka and SQS support message batching. Sending messages in batches reduces network overhead and increases throughput significantly.

  8. Implement circuit breakers: When downstream services are unavailable, circuit breakers prevent cascading failures. Stop consuming messages temporarily and let the queue absorb the backlog.

Common Pitfalls and Solutions

PitfallImpactSolution
No dead letter queueFailed messages lost or infinitely retriedConfigure DLQ on every queue
Non-idempotent consumersData corruption on duplicate deliveryDesign for exactly-once semantics at the application level
Too many partitions (Kafka)Increased latency and overheadStart with 2-3x the number of consumers; increase only when needed
Ignoring consumer lagGrowing backlog and delayed processingMonitor lag and scale consumers accordingly
Large message payloadsPerformance degradationStore large payloads in object storage; pass references in messages
No connection poolingConnection exhaustion under loadUse connection pools and reuse channels

Performance Optimization

Kafka achieves the highest throughput through sequential I/O and zero-copy transfers. For maximum throughput:

// Kafka producer batching configuration
const producer = kafka.producer({
    maxInFlightRequests: 5,
    idempotent: true,
    transactionalId: 'order-producer',
    compression: CompressionTypes.LZ4,
    batch: {
        size: 16384,        // 16KB batch size
        lingerMS: 10,       // Wait up to 10ms to fill batch
    },
});

RabbitMQ performance improves with prefetch tuning:

// RabbitMQ consumer prefetch
channel.prefetch(10);  // Process up to 10 messages concurrently
 
// Publisher confirms for reliability
await channel.assertQueue('orders', { durable: true });
channel.on('return', (msg) => {
    console.error('Message returned:', msg);
});

SQS performance improves with batching:

// SQS batch send
import { SendMessageBatchCommand } from '@aws-sdk/client-sqs';
 
const messages = Array.from({ length: 10 }, (_, i) => ({
    Id: String(i),
    MessageBody: JSON.stringify({ orderId: `order-${i}` }),
}));
 
await sqs.send(new SendMessageBatchCommand({
    QueueUrl: queueUrl,
    Entries: messages,
}));

Comparison with Alternatives

FeatureRabbitMQKafkaSQS
Throughput~50K msg/s~1M msg/s~3K msg/s (standard)
LatencySub-ms~2ms~10-100ms
Message OrderingPer-queuePer-partitionPer-group (FIFO)
Delivery GuaranteeAt-least-onceAt-least-once/exactly-onceAt-least-once/exactly-once
Message ReplayNoYesNo
RetentionUntil consumedTime/size-based14 days max
RoutingComplex (exchanges)Topic-basedSimple (queue)
Operational OverheadMediumHighNone (managed)
CostSelf-hostedSelf-hostedPer-request pricing

Choose RabbitMQ for complex routing and traditional message brokering. Choose Kafka for event streaming, replay capability, and high throughput. Choose SQS for simplicity, zero operational overhead, and AWS integration.

Advanced Patterns and Techniques

Event Sourcing with Kafka

Kafka's log-based architecture naturally supports event sourcing:

class EventStore {
    private producer: Producer;
 
    async appendEvent(aggregateId: string, event: DomainEvent) {
        await this.producer.send({
            topic: 'events',
            messages: [{
                key: aggregateId,
                value: JSON.stringify({
                    ...event,
                    timestamp: Date.now(),
                    version: await this.getNextVersion(aggregateId),
                }),
            }],
        });
    }
 
    async getEvents(aggregateId: string): Promise<DomainEvent[]> {
        const consumer = kafka.consumer({ groupId: `replay-${aggregateId}` });
        await consumer.connect();
        await consumer.subscribe({ topic: 'events', fromBeginning: true });
 
        const events: DomainEvent[] = [];
        await consumer.run({
            eachMessage: async ({ message }) => {
                const event = JSON.parse(message.value!.toString());
                if (event.aggregateId === aggregateId) {
                    events.push(event);
                }
            },
        });
        await consumer.disconnect();
        return events;
    }
}

Saga Pattern with Message Queues

Implement distributed transactions using the saga pattern:

class OrderSaga {
    async execute(order: Order) {
        const sagaId = uuid();
 
        try {
            // Step 1: Reserve inventory
            await this.publishStep(sagaId, 'reserve-inventory', order);
            await this.waitForCompletion(sagaId, 'reserve-inventory');
 
            // Step 2: Process payment
            await this.publishStep(sagaId, 'process-payment', order);
            await this.waitForCompletion(sagaId, 'process-payment');
 
            // Step 3: Confirm order
            await this.publishStep(sagaId, 'confirm-order', order);
        } catch (error) {
            // Compensate in reverse order
            await this.compensate(sagaId, order);
        }
    }
 
    private async compensate(sagaId: string, order: Order) {
        await this.publishStep(sagaId, 'reverse-payment', order);
        await this.publishStep(sagaId, 'release-inventory', order);
    }
}

Testing Strategies

Test message queue integrations with embedded brokers:

import { GenericContainer, StartedTestContainer } from 'testcontainers';
 
describe('Message Queue Integration', () => {
    let container: StartedTestContainer;
 
    beforeAll(async () => {
        container = await new GenericContainer('rabbitmq:3-management')
            .withExposedPorts(5672, 15672)
            .start();
    }, 60000);
 
    afterAll(async () => {
        await container.stop();
    });
 
    it('should publish and consume messages', async () => {
        const connection = await amqp.connect(
            `amqp://localhost:${container.getMappedPort(5672)}`
        );
        const channel = await connection.createChannel();
        await channel.assertQueue('test-queue');
 
        const received: any[] = [];
        channel.consume('test-queue', (msg) => {
            if (msg) {
                received.push(JSON.parse(msg.content.toString()));
                channel.ack(msg);
            }
        });
 
        channel.sendToQueue('test-queue', Buffer.from(JSON.stringify({ test: true })));
 
        await new Promise(resolve => setTimeout(resolve, 100));
        expect(received).toHaveLength(1);
        expect(received[0].test).toBe(true);
    });
});

Future Outlook

Message queuing is evolving toward event-driven architectures with event mesh patterns that connect multiple brokers across clouds and regions. Kafka's KRaft mode eliminates ZooKeeper dependency, simplifying operations. RabbitMQ is improving with Quorum Queues for better consistency and Streams for Kafka-like log semantics. Cloud-native message brokers like AWS EventBridge and Azure Event Grid provide serverless event routing with pay-per-event pricing.

Conclusion

Message queues are essential infrastructure for distributed systems. RabbitMQ excels at complex routing and traditional message brokering. Kafka provides unmatched throughput and replay capability for event streaming. SQS offers simplicity and zero operational overhead for AWS workloads. The right choice depends on your throughput requirements, ordering needs, operational capacity, and cloud strategy.

Key takeaways: always use dead letter queues, implement idempotent consumers, and monitor consumer lag. Choose RabbitMQ for routing complexity, Kafka for streaming and replay, and SQS for managed simplicity. Start with the simplest option that meets your requirements and migrate only when you hit its limits.

For further reading, consult the RabbitMQ tutorials, the Kafka documentation, and the AWS SQS developer guide. Martin Kleppmann's "Designing Data-Intensive Applications" provides excellent context on distributed messaging patterns.


Message queue systems