Codetail

Article 9 of 15

Message Queues & Event Streams

Decouple producers and consumers. Handle backpressure.

24 min read

The Coupling Problem

Synchronous APIs couple callers to the availability, latency, and correctness of their dependencies. When service A calls service B and waits for a response, A inherits B's failure modes. If B is slow, A is slow. If B is down, A errors. If B starts returning 5xx errors, A propagates them to its own callers. The failure domain of one service becomes the failure domain of every service that depends on it, transitively.

Message queues and event streams break this coupling. The producer writes a message to a broker and returns immediately, without knowing who will process the message or when. The consumer reads from the broker when it is ready, without knowing who produced the message. The broker absorbs the mismatch: producers can be faster than consumers for a while (buffering), consumers can be in different datacenters (routing), consumers can fail and reconnect without losing messages (durability).

Synchronous vs async: a concrete example

A checkout endpoint that synchronously calls email, invoice, and loyalty services before returning to the client takes over 5 seconds and inherits three failure modes. The same endpoint that publishes a single order.created event returns in 12 ms. The downstream work happens asynchronously, in parallel, independently.

ScenarioModelLatencyRisk
Checkout API waits for email to sendsync1.2 sEmail timeout = checkout timeout
Checkout API waits for PDF invoice to generatesync3.4 sPDF service outage = checkout outage
Checkout API waits for loyalty points to updatesync0.8 sPoints DB slowdown = slow checkout
Checkout API publishes order.created eventasync12 msIsolated, downstream failures do not propagate

What a queue buys you

+

Decoupling

Producer and consumer do not need to be running at the same time. Each evolves independently. Adding a new consumer requires no change to the producer.

+

Buffering

When bursts of traffic arrive faster than consumers can process, the queue absorbs the difference. Consumers drain it at their own pace. No requests dropped, no timeouts.

+

Durability

Messages persisted to disk survive consumer restarts. If the email service crashes, the order.created messages wait in the queue until it comes back.

+

Load distribution

Multiple consumer instances share the queue. More instances = more throughput. Scale consumers independently of producers without any coordination.

+

Failure isolation

A slow or failing consumer does not cascade to the producer. The producer publishes and moves on. The system degrades gracefully rather than failing completely.

+

Retry without burden on the caller

The broker (or consumer) handles retries with backoff. The producer does not need to implement retry logic. Failed messages go to a dead-letter queue for inspection.

Backpressure

Backpressure is what happens when producers outrun consumers for long enough that the queue itself becomes a problem. A queue that grows unbounded will eventually exhaust memory or disk. Backpressure is the mechanism by which a system signals "slow down" to its upstream.

Backpressure strategies

Block the producer

Producer blocks on publish until the queue has capacity. Simple but couples producer to queue depth. Only works when producers can tolerate blocking.

Drop messages

When the queue is full, new messages are dropped. Acceptable for metrics and telemetry where data loss is tolerable. Never acceptable for financial events.

Reject at the edge

The API gateway returns 429 Too Many Requests when the downstream queue is full. The caller retries later. Keeps the queue bounded; pushes retry burden to clients.

Scale consumers

Add consumer instances when queue depth exceeds a threshold. Auto-scaling based on queue depth (SQS + CloudWatch, Keda on Kubernetes) handles this automatically.

Queue vs Stream: Two Different Primitives

The terms "message queue" and "event stream" are often used interchangeably, but they represent fundamentally different primitives. A queue is a work distribution mechanism: each message is a task to be done by exactly one worker. A stream is an immutable, ordered log: each message is a fact that any number of consumers can read independently, now or in the future.

The key behavioral difference is what happens to a message after it is read. In a queue, the consumer acknowledges it and the message is deleted. In a stream, the message stays on disk. The consumer advances its offset. Other consumers have their own offsets and read the same message at their own pace. A new consumer can replay the entire history from offset zero.

Queue vs Stream Visualizer

Each message is delivered to exactly one consumer then deleted. Consumers compete for messages.

Producer

Queue

empty, publish some messages above

Property comparison

PropertyQueueStream
Message retentionDeleted after consumptionRetained (configurable TTL)
Consumer modelCompeting consumers (one gets each)Consumer groups with independent offsets
ReplayNot possible, message goneYes, rewind offset to any point
OrderingFIFO within a queueStrict order within a partition
Scaling readsAdd consumers to the same queueAdd consumer groups (each reads all)
Delivery guaranteeAt-least-once (ack-based)At-least-once; exactly-once with transactions
BackpressureQueue depth builds; reject or blockConsumers lag behind; lag is observable
Primary useTask distribution, job queuesEvent sourcing, audit logs, stream processing

Real systems

RabbitMQQueueAMQP

Best for: Task queues, work distribution, request routing via exchange bindings

Flexible routing via exchanges (direct, fanout, topic, headers). Messages TTL and DLQ built in.

Amazon SQSQueueHTTP/HTTPS

Best for: Managed task queue, serverless workers, Lambda triggers

Standard (at-least-once, best-effort ordering) and FIFO (exactly-once, strict order) queues.

Apache KafkaStreamKafka binary / TCP

Best for: Event sourcing, real-time pipelines, audit logs, analytics

Partitioned log. Messages retained on disk. Consumer groups each get full copy. Millions of events/sec.

Google Pub/SubQueue + StreamHTTP/gRPC

Best for: Managed messaging for GCP workloads, push to Cloud Functions

Managed service. At-least-once delivery. Seek to timestamp for replay. Simple ops story.

Amazon KinesisStreamHTTP/HTTPS

Best for: Real-time analytics, log aggregation on AWS

Sharded stream. 24-hour default retention (7-day extended). Tight AWS integration.

Redis StreamsStreamRESP / TCP

Best for: Low-latency streams when Redis is already in the stack

Consumer groups with offset tracking. Not a Kafka replacement at volume, but operationally simple.

Delivery Guarantees and Messaging Patterns

Delivery guarantees

Every messaging system makes a promise about what happens to a message when something goes wrong. The guarantee is a trade-off between latency, complexity, and correctness. Understanding which guarantee your system provides is the most important operational decision when adopting a message broker.

At-most-oncefire and forget

Behavior: Message sent once. If the consumer crashes before processing, message is lost. No retry.

How: Publish and forget. No ack. RabbitMQ auto-ack mode. UDP-style.

Cost: Zero broker overhead. Lowest latency.

Use when: Metrics, logs, telemetry where occasional loss is acceptable.

At-least-onceack-based retry

Behavior: Message delivered until the consumer explicitly acks it. Consumer crashes = redeliver. Message may be processed more than once.

How: Consumer acks after processing. Broker retains message until ack received. RabbitMQ manual ack, SQS visibility timeout, Kafka offset commit.

Cost: Consumers must be idempotent. Duplicate processing is normal, not an error.

Use when: Most production workloads. Default choice. Design consumers to handle duplicates.

Exactly-oncetransactional delivery

Behavior: Each message processed exactly one time, even with failures and retries. The holy grail.

How: Requires coordination: idempotency keys (deduplicate on consumer side), or distributed transactions (Kafka transactions, SQS FIFO with deduplication ID).

Cost: Significant complexity and latency overhead. True exactly-once is often impossible across system boundaries, settle for idempotent at-least-once instead.

Use when: Financial transactions, inventory deduction. Usually implemented as idempotent at-least-once, not true exactly-once.

In practice, most teams implement idempotent at-least-oncedelivery and call it "effectively exactly-once." The consumer deduplicates using an idempotency key (message ID, event UUID). Processing the same message twice produces the same outcome. This is simpler and more reliable than true exactly-once semantics.

Messaging patterns

Competing ConsumersQueue
Producer → [Queue] → Consumer 1
                  → Consumer 2
                  → Consumer 3

Multiple consumer instances read from the same queue. Each message goes to one consumer. Adding instances scales throughput linearly. The queue distributes work automatically.

Use for: Job queues, order processing, background task execution.

Watch out: Ensure consumers are stateless or that shared state is coordinated (e.g., database row lock). Order across consumers is not guaranteed.

Pub/Sub (Fanout)Both
Producer → [Topic/Exchange]
                → Consumer Group A (billing)
                → Consumer Group B (email)
                → Consumer Group C (analytics)

One event, many independent consumers. Each consumer group receives all messages. New consumers can subscribe without producer changes. Classic event-driven architecture.

Use for: order.created triggers billing, email, inventory, and analytics independently.

Watch out: In a stream (Kafka), consumer groups each get a full copy. In a queue (RabbitMQ fanout exchange), a copy is created per binding.

Dead Letter Queue (DLQ)Queue
Consumer fails 3x → [DLQ]
                     ↓
               Inspect + replay

Messages that fail processing N times (configurable) are moved to a DLQ instead of being retried forever. Engineers inspect and replay or discard them after investigation.

Use for: Every production queue should have a DLQ. Without one, poison messages block the queue or spin in retry loops consuming resources.

Watch out: Set DLQ alerts. A growing DLQ is a consumer bug or bad message format. Review DLQ contents on deploy.

Event SourcingStream
Commands → [Event Log]
                → Current state = replay(all events)
                → Projection A (read model)
                → Projection B (audit log)

The event log is the source of truth. Current state is derived by replaying events from the log. State can be reconstructed at any point in time. Kafka is the natural storage layer.

Use for: Financial ledgers, inventory systems, audit trails where history matters more than current state.

Watch out: Schema evolution is hard. Events are immutable, a bug in event structure is permanent. Versioning and upcasting strategy required from day one.

Saga PatternBoth
order.created → reserve inventory
               → payment.charged → ship order
               ← payment.failed → release inventory (compensate)

Long-running distributed transactions implemented as a sequence of local transactions and compensating actions. Each step publishes an event. If a step fails, compensating events undo prior steps.

Use for: Multi-service workflows: checkout (payment + inventory + shipping). Replaces two-phase commit across services.

Watch out: Compensating actions must be idempotent. Partial failures leave systems in intermediate states. Observability into saga state is essential.

Choosing a Messaging System

RabbitMQ, SQS, and Kafka serve different primary use cases. RabbitMQ is a message broker with flexible routing. SQS is a fully managed queue optimized for simple AWS workloads. Kafka is a distributed log built for high-throughput streaming and event sourcing. Choosing the wrong one creates operational friction that compounds over time.

Decision matrix

SignalRabbitMQSQSKafkaNotes
Task/job queue, one worker per messagebest fitbest fitworkableRabbitMQ and SQS are purpose-built for work distribution. Kafka works but is heavyweight for simple task queues.
Event sourcing, replay from any offsetavoidavoidbest fitKafka's retained, ordered log is the natural fit. RabbitMQ and SQS delete messages on consumption.
Many consumers reading same events independentlyworkableavoidbest fitKafka consumer groups each get a full copy. SQS requires separate queues per consumer. RabbitMQ fanout exchange duplicates messages.
Sub-millisecond latency, low throughputbest fitavoidworkableRabbitMQ delivers in-memory messages with sub-ms latency. SQS has ~1ms+ HTTP overhead. Kafka optimizes for throughput, not latency.
Millions of events per secondavoidworkablebest fitKafka's partitioned log and batch compression handle extreme throughput. RabbitMQ saturates around tens of thousands/sec per queue.
Complex routing (topic, header, binding rules)best fitavoidworkableRabbitMQ exchange types (direct, topic, fanout, headers) handle sophisticated routing natively. Kafka routing is by topic/partition only.
Fully managed, zero ops overheadavoidbest fitworkableSQS is serverless and infinitely scalable with zero management. RabbitMQ requires cluster ops. MSK and Confluent Cloud manage Kafka.
Strict FIFO ordering with deduplicationworkablebest fitworkableSQS FIFO queues guarantee ordering and deduplication. Kafka guarantees order per partition. RabbitMQ FIFO is per-queue, not globally ordered.
Long-term audit log / complianceavoidavoidbest fitKafka can retain messages indefinitely (log compaction or infinite retention). SQS max 14 days. RabbitMQ deletes on ack.
Already on AWS, need Lambda triggersavoidbest fitworkableSQS + Lambda is first-class AWS. MSK (Kafka) also has Lambda trigger support but more complex setup.

Operational cost

RabbitMQ

Setup: Medium, cluster needs quorum nodes

Scaling: Vertical (bigger nodes) or sharding (multiple clusters). Horizontal is complex.

Monitor: Queue depth, consumer count, unrouted messages, connection count

Failure: Node failure in cluster handled by quorum. Single-node: manual recovery.

SQS

Setup: Zero, fully managed

Scaling: Infinite. Scales automatically. Cost scales with messages, not infra.

Monitor: ApproximateNumberOfMessagesVisible (depth), NumberOfMessagesSent/Deleted

Failure: AWS handles it. 99.9% SLA. No cluster ops.

Kafka

Setup: High, ZooKeeper (or KRaft), broker fleet, partition strategy required

Scaling: Add brokers + rebalance partitions. Horizontal scaling is a core design feature.

Monitor: Consumer lag per topic/partition is the critical metric. Under-replicated partitions, ISR, disk utilization.

Failure: Partition leader election on broker failure (seconds). No data loss if replication factor >= 2.

Rules of thumb

Start with SQS if you are on AWS and need a task queue

Zero setup, infinite scale, pay per message. Add SNS as a fanout layer when you need pub/sub. The managed ops story is unbeatable for most teams.

Use RabbitMQ when you need rich routing logic

Topic exchanges, header-based routing, priority queues, and dead-lettering are first-class features. No Kafka partition planning required.

Use Kafka when messages are facts, not tasks

If you need replay, audit log, stream processing, or multiple independent consumers reading the same events, Kafka's retained log is the right model.

Design consumers to be idempotent from day one

At-least-once delivery is the practical default for every system. Duplicate messages will arrive. Build consumers that handle them gracefully using idempotency keys.

Monitor consumer lag, not just queue depth

In Kafka, lag (how far behind the latest offset a consumer is) is the primary health signal. A consumer that is always caught up is healthy. Growing lag is an incident.

Every queue needs a dead-letter queue

Without a DLQ, poison messages either loop forever (burning CPU) or block the queue. A DLQ catches failures, alerts engineers, and enables replay after the bug is fixed.