The Runtime Theory
Backend Engineering

Message Queues: Decoupling the Hard Part

Kafka, RabbitMQ, SQS: when to use each, exactly-once vs at-least-once semantics, and why message ordering is harder than it looks.

The Runtime Theory Team12 min read#message-queue#kafka#rabbitmq#sqs#event-driven
On this page

Message queues decouple producers from consumers, enabling asynchronous processing, load leveling, and fault tolerance. But the "simple" abstraction hides deep complexity: ordering guarantees, delivery semantics, backpressure, and poison messages. This article compares the major queue systems and explains when each excels.

The Three Delivery Semantics

python
# At-most-once: fire and forget
# Message is sent, no acknowledgment. Lost messages are acceptable.
def at_most_once_send(queue, message):
    queue.publish(message)  # no ack, no retry
 
# At-least-once: ack required, retry on failure
# Message may be delivered multiple times if ack is lost.
def at_least_once_send(queue, message):
    while True:
        try:
            queue.publish(message)
            queue.wait_for_ack()
            break
        except TimeoutError:
            pass  # retry — may duplicate
 
# Exactly-once: idempotent processing + transactional ack
# The holy grail. Requires consumer idempotency.
def exactly_once_send(queue, message):
    with queue.transaction():
        queue.publish(message)
        # Ack and message in same transaction
        # Consumer must handle duplicates via idempotency key

Apache Kafka: The Log-Based Queue

Kafka is a distributed commit log, not a traditional queue. Messages are appended to partitioned topics and retained for a configurable duration:

python
from kafka import KafkaProducer, KafkaConsumer
import json
 
# Producer: partition by key for ordering
producer = KafkaProducer(
    bootstrap_servers=['localhost:9092'],
    value_serializer=lambda v: json.dumps(v).encode('utf-8'),
    acks='all',           # wait for all replicas
    retries=3,
    enable_idempotence=True  # exactly-once producer
)
 
# Messages with same key go to same partition → ordering guaranteed
producer.send('orders', value={'order_id': 123}, key='customer_42')
producer.send('orders', value={'order_id': 124}, key='customer_42')
# Both go to same partition → consumed in order
 
# Consumer: group-based consumption
consumer = KafkaConsumer(
    'orders',
    group_id='order-processor',
    auto_offset_reset='earliest',
    enable_auto_commit=False,  # manual commit for at-least-once
    consumer_timeout_ms=1000
)
 
for message in consumer:
    process_order(message.value)
    consumer.commit()  # commit offset after processing
python
# Kafka partitioning strategy:
def partition_key(order: dict) -> str:
    """Partition by customer_id for per-customer ordering."""
    return order['customer_id']
 
# Partition 0: customer_1, customer_7, customer_13, ...
# Partition 1: customer_2, customer_8, customer_14, ...
# Partition 2: customer_3, customer_9, customer_15, ...
 
# Key insight: ordering is per-partition, not per-topic
# If you need global ordering: use 1 partition (limits throughput)

RabbitMQ: The Traditional Queue

RabbitMQ is a message broker with routing, acknowledgments, and priority queues:

python
import pika
 
# Publisher with routing
connection = pika.BlockingConnection(pika.ConnectionParameters('localhost'))
channel = connection.channel()
 
# Declare exchange and queue
channel.exchange_declare(exchange='orders', exchange_type='direct')
channel.queue_declare(queue='order_processing', durable=True)
channel.queue_bind(exchange='orders', queue='order_processing',
                   routing_key='process')
 
# Publish with persistent message
channel.basic_publish(
    exchange='orders',
    routing_key='process',
    body=json.dumps({'order_id': 123}),
    properties=pika.BasicProperties(
        delivery_mode=2,  # persistent
        message_id='order_123'
    )
)
 
# Consumer with manual acknowledgment
def callback(ch, method, properties, body):
    try:
        process_order(json.loads(body))
        ch.basic_ack(delivery_tag=method.delivery_tag)
    except Exception as e:
        # Negative ack → requeue or dead letter
        ch.basic_nack(delivery_tag=method.delivery_tag, requeue=True)
 
channel.basic_consume(queue='order_processing', on_message_callback=callback)
channel.start_consuming()

Amazon SQS: Managed Simplicity

SQS provides managed message queuing with minimal configuration:

python
import boto3
 
sqs = boto3.client('sqs')
 
# Send message
sqs.send_message(
    QueueUrl='https://sqs.us-east-1.amazonaws.com/123456789/orders',
    MessageBody=json.dumps({'order_id': 123}),
    MessageAttributes={
        'Priority': {'DataType': 'String', 'StringValue': 'high'}
    }
)
 
# Receive messages with long polling
response = sqs.receive_message(
    QueueUrl='https://sqs.us-east-1.amazonaws.com/123456789/orders',
    MaxNumberOfMessages=10,
    WaitTimeSeconds=20,  # long polling
    MessageAttributeNames=['All']
)
 
for message in response['Messages']:
    process_order(json.loads(message['Body']))
    # Delete message after processing
    sqs.delete_message(
        QueueUrl='https://sqs.us-east-1.amazonaws.com/123456789/orders',
        ReceiptHandle=message['ReceiptHandle']
    )

Comparison Matrix

python
# Feature comparison:
features = {
    "Throughput": {
        "Kafka": "Millions/sec (partitioned)",
        "RabbitMQ": "Tens of thousands/sec",
        "SQS": "Tens of thousands/sec (standard)"
    },
    "Ordering": {
        "Kafka": "Per-partition (global with 1 partition)",
        "RabbitMQ": "Per-queue (FIFO available)",
        "SQS": "Best-effort (standard), FIFO queue available"
    },
    "Delivery": {
        "Kafka": "At-least-once (exactly-once with transactions)",
        "RabbitMQ": "At-least-once (explicit ack)",
        "SQS": "At-least-once (visibility timeout)"
    },
    "Retention": {
        "Kafka": "Configurable (days to forever)",
        "RabbitMQ": "Until consumed",
        "SQS": "Up to 14 days"
    }
}

tradeoff / Kafka vs RabbitMQ vs SQS

For most microservice architectures, SQS or a managed Kafka (Confluent, MSK) is the right choice. Self-managed RabbitMQ requires significant operational effort for clustering and monitoring.

Kafka for event streaming and log aggregation. RabbitMQ for task queues with complex routing. SQS for simple decoupled services on AWS. Don't over-engineer: SQS handles 90% of queue use cases.

Synthesis

Message queues are the backbone of event-driven architectures, but the choice between Kafka, RabbitMQ, and SQS depends on throughput requirements, ordering guarantees, and operational capacity. Kafka excels at high-throughput streaming with replay. RabbitMQ provides flexible routing with low latency. SQS offers managed simplicity with zero operations.