Message queues
Learn how message queues decouple services, which delivery guarantee fits your workload, and how to build a queue layer that survives consumer failures.
Introduction
A message queue is a durable handoff for work that does not need to finish inside the producer's request. Mental model: the producer records a message, the broker holds it under a retention/durability policy, and consumers pull or receive work at a sustainable rate. The queue improves isolation and burst handling, but introduces lag, duplicate delivery, retry policy, and another stateful system to operate.
TL;DR
- A message queue is an async communication intermediary that decouples producers from consumers β producers write messages to a broker configured for the required durability, and consumers read and process them independently, on their own schedule.
- The core trade-off is fault isolation and burst handling vs. operational complexity and eventual consistency: a queue can absorb failures or bursts, but it makes end-to-end behavior harder to reason about.
- At-least-once delivery is a common default. In that setup, producers or brokers retry until the handoff is acknowledged, so consumers need idempotency β processing the same message twice should produce the same result as processing it once.
- Use queues when downstream work is variable or slow, when services need to decouple failure domains, or when traffic spikes threaten a downstream service you don't own. A threshold such as 200ms is only an illustrative starting point.
- Kafka fits high-throughput event streaming with replay; RabbitMQ or SQS often fit task queues and command routing. They are not interchangeable, so choose from retention, routing, ordering, throughput, and operational requirements.
The Problem It Solves
It's Black Friday. Your checkout API synchronously calls four services: inventory update (100ms), confirmation email (300ms), analytics event (150ms), push notification (200ms). That's 750ms of blocking wait per checkout. For months this worked fine in staging. Under production Black Friday traffic, the confirmation email service hits its provider rate limit and response times climb from 300ms to 5,000ms.
Your checkout API β waiting synchronously on the email call β starts timing out. Your load balancer returns 503s. Checkout traffic can fail broadly even though the email service is the component that degraded.
The hidden coupling in every synchronous architecture
Synchronous service calls create implicit availability chains. If four dependencies are independent and each is available 99.9% of the time, the composed path is roughly 99.9%β΄ β 99.6% β about 35 hours per year. Real systems also have shared failure modes, retries, and fallbacks, so treat this as an illustrative upper-level model.
The problem is not that the email service is slow. It's that you made checkout success conditional on email delivery. Those are different concerns β they don't need to complete in the same HTTP response cycle.
What Is It?
A message queue is an async communication pattern where a producer writes a message to an intermediary broker, and one or more consumers read and process it independently, on their own schedule. The broker retains the message according to its retention, durability, and acknowledgement or offset policy.
Analogy: A restaurant kitchen. When a waiter takes an order, they don't stand at the table until the chef finishes cooking. They walk the ticket to the kitchen, pin it to the rail, and go take the next order. The kitchen is the queue. The ticket is the message. The chef is the consumer. The waiter (your HTTP API) is free the moment the ticket hits the rail β the kitchen's backlog doesn't block the front-of-house.
That separation is the fundamental insight. The checkout API's job is to record intent: "customer X bought product Y for $Z." What happens next β reducing inventory, sending an email, updating analytics β can happen asynchronously, in parallel, at the consumers' own pace.
With a queue in place, the email service can be slow, down, or mid-deployment without blocking the checkout response. The checkout can succeed while the email message waits for recovery, subject to broker retention, durable publish acknowledgement, retry limits, and the email provider's own behavior.
How It Works
Here's what happens on every checkout request when a message queue is in use:
- Producer publishes a message β The checkout API validates the order, writes it to the database, then publishes a
checkout.completedevent to the broker. The latency is workload- and configuration-dependent; the API returns only after the required publish acknowledgement. - Broker persists the message β A durable queue stores the message according to its replication and retention settings. Consumer crashes do not remove an acknowledged message, but broker durability, retention, and publish settings still define the recovery guarantee.
- Consumer polls or receives β Consumers either pull messages (Kafka, SQS, Redis Streams) or receive pushed deliveries (RabbitMQ push mode). Either way, the consumer independently fetches the next message.
- Consumer processes the message β The email worker reads the event, calls Sendgrid, and sends the email. This takes however long it takes β 300ms normally, 30 seconds during degradation. The producer need not wait for this consumer, although the consumer can still affect backlog and downstream health.
- Consumer ACKs the message β On successful processing, the consumer sends an acknowledgment. A queue may remove the message, while a log-backed broker advances the consumer position; retention and replay semantics depend on the broker.
- On failure: NACK + retry β If the consumer crashes or fails before ACKing, the message re-appears after the visibility timeout expires. After N retries, it routes to the Dead Letter Queue (DLQ).
// producer.ts β Checkout API publishes an event on successful order creation
import { SQSClient, SendMessageCommand } from "@aws-sdk/client-sqs";
const sqs = new SQSClient({ region: "us-east-1" });
async function checkout(order: Order): Promise<void> {
// Step 1: Write to DB β source of truth first
const savedOrder = await db.orders.create(order);
// Step 2: Publish event β latency depends on broker, network, and durability settings
// β οΈ Simplified for clarity. In production, use the Transactional Outbox pattern
// (see Q5 in Test Your Understanding) to reduce the lost-event window
// if this process crashes between the DB write and this send call.
await sqs.send(new SendMessageCommand({
QueueUrl: process.env.CHECKOUT_QUEUE_URL,
MessageBody: JSON.stringify({
type: "checkout.completed",
orderId: savedOrder.id,
customerId: savedOrder.customerId,
totalCents: savedOrder.totalCents,
publishedAt: new Date().toISOString(),
}),
// MessageGroupId requires a FIFO queue (URL ending in .fifo)
// Remove this line for SQS Standard queues or you'll get InvalidParameterValue
// For FIFO queues: also supply MessageDeduplicationId per message, OR enable
// ContentBasedDeduplication on the queue at creation time β otherwise SQS rejects the send
MessageGroupId: savedOrder.customerId,
}));
// Step 3: Return immediately β no waiting on workers
return; // 200 OK β user sees confirmed checkout instantly
}
// email-worker.ts β Consumes checkout events and sends confirmation emails
import { SQSClient, ReceiveMessageCommand, DeleteMessageCommand } from "@aws-sdk/client-sqs";
const sqs = new SQSClient({ region: "us-east-1" });
async function processNextBatch(): Promise<void> {
const response = await sqs.send(new ReceiveMessageCommand({
QueueUrl: process.env.CHECKOUT_QUEUE_URL,
MaxNumberOfMessages: 10,
WaitTimeSeconds: 20, // Long-polling: fewer empty receives, lower cost
VisibilityTimeout: 60, // 60s to process before SQS assumes worker crashed
}));
for (const msg of response.Messages ?? []) {
const event = JSON.parse(msg.Body!);
try {
await sendConfirmationEmail(event.customerId, event.orderId);
// Only delete AFTER successful processing β this is the ACK
await sqs.send(new DeleteMessageCommand({
QueueUrl: process.env.CHECKOUT_QUEUE_URL,
ReceiptHandle: msg.ReceiptHandle!,
}));
} catch (err) {
// Do NOT delete β SQS redelivers after VisibilityTimeout expires
console.error("Email worker failed, message will be redelivered", err);
}
}
}
Make the ACK/NACK contract explicit
The consumer should ACK only after the side effect is complete. If it crashes before ACKing, a configured broker can redeliver after the visibility timeout; after a bounded retry count, route the message to a DLQ and alert on-call. The application still needs idempotency because the broker cannot distinguish a completed side effect from a lost ACK.
Key Vocabulary and Components
| Component | Role |
|---|---|
| Producer | The service that creates and publishes messages. Producers don't know how many consumers exist or when messages will be processed. |
| Consumer | The service that reads and processes messages. Multiple consumers can read from the same queue simultaneously (competing consumers pattern). |
| Broker | The intermediary that receives, stores, and delivers messages. Examples: Kafka, RabbitMQ, Amazon SQS, Redis Streams. Its replication, retention, and acknowledgement settings define the durability contract. |
| Queue / Topic | A named channel within the broker. A queue normally assigns each message to one active consumer (work dispatch), with duplicates possible after failure. A topic broadcasts to all subscribers (fan-out). |
| Message | A discrete unit of work β typically a payload with a type, ID, and data. Keep messages bounded and self-contained enough for the consumer; payload limits vary by broker. |
| Acknowledgment (ACK) | The consumer's signal that a message was processed successfully. Without an ACK, a configured broker can redeliver; safe at-least-once effects still require idempotency. |
| Visibility Timeout | The window during which a delivered message is hidden from other consumers. If the consumer doesn't ACK within this window, the message becomes visible again for redelivery. SQS default: 30 seconds. |
| Dead Letter Queue (DLQ) | A separate queue where messages land after exceeding the maximum retry count. A DLQ message is an operational signal β inspect the payload, code path, and dependency before deciding whether to replay or discard it. |
| Consumer Group | A logical grouping of consumers sharing the processing load of a queue or topic. In a group, a message is normally assigned to one active member at a time; delivery can repeat after failure. |
| Partition | A Kafka concept β a topic is split into N ordered partitions, each consumed by at most one consumer in a group at a time. Partitions are the unit of parallelism in Kafka, and partition count caps concurrent consumer assignments in a group. |
Types / Variations
Point-to-Point (Queue)
Each message is assigned to one active consumer at a time. If you have five email worker instances polling the same queue, each checkout event is normally picked up by one of them, but a failure before acknowledgement can cause redelivery. This is the right model for distributing work β order processing, email sending, invoice generation, background jobs.
Publish/Subscribe (Topic Fan-out)
Each message is delivered to every subscriber independently. When checkout publishes checkout.completed, the email service, analytics service, inventory service, and notification service each receive their own copy and process it independently. This is the right model for broadcasting events β one write, N reactions.
Push vs. Pull Consumption
| Model | How it works | Best for | Example |
|---|---|---|---|
| Pull | Consumer polls the broker on its own schedule | Controlled throughput Β· backpressure by design | Kafka, SQS, Redis Streams |
| Push | Broker delivers messages to consumer endpoint | Low-latency, event-driven processing | RabbitMQ, webhooks |
Pull lets consumers control their ingestion rate and apply bounded batches, but it still needs backpressure and concurrency limits. Push can reduce delivery latency but requires explicit admission control so consumers are not overwhelmed under high throughput.
Kafka vs. RabbitMQ vs. Amazon SQS
| Dimension | Kafka | RabbitMQ | Amazon SQS |
|---|---|---|---|
| Primary model | Distributed append-only log | Message broker with routing | Fully managed simple queue |
| Throughput | High; cluster and workload dependent | High; cluster and workload dependent | Service quotas apply; batching and queue type change the limit |
| Message retention | Days to weeks (configurable) | Until ACKed β no replay | 4 days default (14 max) |
| Replay | Yes β rewind offset by timestamp | No | No |
| Ordering | Per-partition order with suitable keys/configuration | FIFO queues available | FIFO queues available |
| Operational complexity | High (KRaft; ZooKeeper removed in Kafka 4.0) | Medium (cluster + plugins) | Lower operator burden (fully managed) |
| Best for | Event streaming, audit logs, data pipelines | Task queues, RPC patterns, complex routing | Simple task queuing in AWS workloads |
| Not for | Simple task queues needing minimal ops | High-throughput streaming | Replay, complex routing |
Throughput, retention, ordering, and operational complexity depend on configuration, workload, and service tier. Treat the table as a comparison of common operating models, not a capacity guarantee.
For an AWS-native task queue, SQS may minimize operations; RabbitMQ fits richer exchange and routing logic; Kafka fits high-throughput event streaming and replay. Choose from the workload and operating constraints rather than platform familiarity or feature count.
Delivery Guarantees
Delivery semantics define what can happen when the producer, broker, or consumer fails. They should be chosen together with the consumer's idempotency and recovery design.
At-Most-Once
The producer fires a message and does not retry. If the broker is down or the consumer crashes before the work is durable, the message can be lost. The upside is no broker-driven duplicate delivery; the downside is possible data loss.
Use when: Telemetry you can afford to lose β click events, page view counts, heartbeat signals. Avoid it for money, inventory, or anything with business consequences unless a separate recovery path exists.
At-Least-Once (The Default)
The producer or broker retries until the required acknowledgment is received. The consumer ACKs only after successful processing. If anything fails in between, the message can be redelivered. The same message may arrive two or more times, so the consumer must handle duplicates.
This is a common default for production workloads. It requires consumers to be idempotent or otherwise make repeated delivery safe.
// β
Idempotent consumer β INSERT-first pattern, safe under concurrent redelivery
async function processOrder(event: CheckoutEvent): Promise<void> {
try {
await db.transaction(async (tx) => {
// INSERT the idempotency marker first β throws unique constraint on duplicate
// This is atomic: one concurrent worker wins the insert; others hit the constraint
await tx.processedEvents.insert({ orderId: event.orderId, processedAt: new Date() });
await tx.orders.updateStatus(event.orderId, "confirmed");
});
} catch (err) {
if (isUniqueConstraintViolation(err)) {
// Another worker already processed this delivery β ACK without reprocessing
return;
}
throw err; // Real failures must NOT be ACKed β redeliver for retry
}
}
// Why not check first then insert? Two concurrent workers can both pass the check
// before either inserts (TOCTOU race), leading to double-processing. INSERT-first
// with constraint handling is one robust pattern for avoiding the race.
Idempotency is your responsibility β the queue doesn't enforce it
At-least-once delivery aims to make a message redeliverable, not to guarantee that it arrives exactly once. Under network partitions or consumer restarts, the same message can appear multiple times. If a consumer charges a credit card, sends an email, or deducts inventory on every delivery, duplicates can be harmful. Build idempotency at the consumer level using a processed-events table or a Redis SET of processed IDs.
Exactly-Once Effects
Exactly-once is a scope-specific property, not a default property of a queue. End-to-end exactly-once effects require the broker, consumer, and side-effecting store or API to participate in a compatible transaction or idempotency protocol. Common approaches include:
- Transactional processing β a broker's transaction support can atomically commit consumed offsets and produced records within the supported system boundary. External database or API effects still need coordination.
- Idempotency and deduplication β at-least-once delivery combined with an idempotency key and a durable processed-events store. This gives an effectively-once business result for the covered operation, not a protocol guarantee for every external effect.
This coordination can be expensive and is often unnecessary. A common practical choice is durable at-least-once delivery with idempotent consumers, plus reconciliation for effects that cross an external boundary.
| Guarantee | Messages lost | Duplicates | Complexity | Use case |
|---|---|---|---|---|
| At-most-once | Possible | Usually no broker redelivery | Low | Non-critical telemetry, ephemeral metrics |
| At-least-once | Possible if durability/retention is misconfigured | Possible | Medium | Most production workloads with idempotent consumers |
| Exactly-once effects | Depends on the boundary | Depends on the boundary | High | Narrow operations with transactional or idempotent side effects |
Failure Modes and Operations
- Lost handoff. A database commit followed by a process crash can leave business state without a message. Use a transactional outbox or another durable handoff when the event is required.
- Duplicate side effects. A worker can finish the external call and crash before ACK. Use an idempotency key or an operation that is naturally safe to repeat; consumer deduplication alone cannot undo a duplicate external charge.
- Poison messages. Bound retries with backoff, record the failure reason, route persistent failures to a DLQ, and replay only after the code or data issue is fixed.
- Backlog growth. Monitor visible and in-flight depth, oldest-message age, consumer throughput, processing latency, downstream saturation, and DLQ depth. Scale consumers only when parallelism is the bottleneck.
- Visibility-timeout mismatch. A timeout shorter than tail processing causes concurrent redeliveries; a timeout much longer than needed delays recovery after crashes. Extend it with heartbeats for long-running work where supported.
- Ordering and overload. FIFO or partition ordering can limit parallelism. Define whether the business needs global, per-key, or no ordering, then apply bounded concurrency and backpressure.
Trade-offs
| Pros | Cons |
|---|---|
| Fault isolation β a consumer crashing doesn't affect the producer or any other consumer | Eventual consistency β no single transaction boundary across producer and consumers |
| Traffic absorption β the queue buffers bursts; consumers drain at their own sustainable rate | Operational overhead β the broker is a new stateful system to deploy, monitor, back up, and scale |
| Independent scaling β add consumer instances to increase throughput without touching producers | Debugging complexity β tracing a message through an async pipeline requires correlation IDs and distributed tracing |
| Durability β persisted messages survive consumer restarts and network partitions | Latency β processing is non-realtime; the queue adds variable lag between publish and consumption |
| Decoupled deployments β producers and consumers can be released independently | Idempotency burden β at-least-once delivery means consumers must handle duplicates; this logic is non-trivial for stateful operations |
| Natural backpressure β queue depth signals overload before consumers crash | Message ordering β guaranteed ordering requires special configuration (Kafka partitioning, SQS FIFO) and limits parallelism |
The fundamental tension here is decoupling vs. observability. Synchronous calls are easy to trace β request in, response out, error thrown. Async pipelines are harder to reason about: a stuck consumer may look healthy while queue depth and message age rise. The resilience gain comes at the direct cost of end-to-end transparency, so correlation IDs and lag dashboards are part of the design.
When to Use It / When to Avoid It
Use message queues when:
- A downstream call is measurably slow or variable and the caller doesn't need its result to return a response to the user; 200ms is only an illustrative threshold.
- You call a service that can be independently slow or unavailable β any third-party API (email, SMS, payment webhooks).
- Traffic spikes are unpredictable and the downstream cannot scale fast enough to absorb them elastically.
- Multiple independent services all need to react to the same event β fan-out without explicit tight coupling.
- Background jobs need to be distributed across many worker instances for horizontal throughput.
- You need durable at-least-once processing β fire-and-forget HTTP calls can drop messages on network failure.
Alternatives
- Synchronous HTTP or gRPC fits work whose result is required before the caller can respond.
- A workflow or saga orchestrator fits long-running multi-step processes with explicit state and compensation.
- A streaming log fits multiple independent consumers, retention, replay, and high sustained throughput better than a short-lived task queue.
- A scheduled batch fits work that can wait and is cheaper to process in bulk.
Avoid message queues (or know the full cost) when:
- The caller needs the downstream result to return a response. A checkout cannot be "confirmed" to the user if inventory deduction is asynchronous β you need to know the seat exists before printing a ticket.
- You haven't built idempotent consumers. At-least-once queues can redeliver duplicates, especially around timeouts and crashes.
- You're prototyping. Queues add infra complexity that obscures bugs. Prove your system works synchronously first, then decouple proven bottlenecks.
- Low-traffic, straightforward workloads where synchronous calls work fine and the downstream is reliable. Not every service interaction needs a queue.
The boundary is simple: keep a synchronous dependency when its result changes the caller's response; queue work that can complete later and whose durability, retry, ordering, and lag you can operate.
Real-World Examples
Netflix β an event-driven media pipeline
A media pipeline can use a log or queue for stream starts, encoding-job completion, quality checks, metadata, and content placement. Each stage can be independently scaled and retried; replay is useful when a projection or downstream processor needs to be rebuilt. The design lesson is to make retention, replay, and external side effects explicit.
Stripe β idempotency as a first-class design principle
Payment APIs commonly expose an idempotency key for write requests; Stripe's public API is a well-known example. The same idea applies to downstream consumers such as ledger updates, email confirmations, and webhook delivery: durable at-least-once delivery plus idempotent effects can be easier to operate than trying to guarantee exactly-once delivery across every boundary.
LinkedIn β Kafka's log model
Kafka originated at LinkedIn to support high-volume activity data and replayable consumers. An append-only log lets a new projection read retained history, while a task queue may remove a message after acknowledgement. The choice depends on whether replay and independent consumer offsets are requirements.
Explain It in 30 Seconds and 5 Minutes
30-second explanation
A message queue is a durable handoff between a producer and an independent consumer. The producer publishes and returns; workers process later, which absorbs bursts and isolates failures. The cost is queue lag, duplicate delivery, retry/DLQ operations, and eventual rather than immediate completion.
5-minute explanation
Start by deciding whether the interaction is work dispatch or event fan-out, then choose a queue or log based on retention, replay, routing, ordering, and throughput. State the delivery guarantee: at-least-once is common, so handlers need idempotency keys or naturally repeatable effects. Describe the ACK boundary, visibility timeout or equivalent lease, bounded retries, and DLQ recovery.
Then show the operating loop: monitor queue depth and oldest-message age, consumer throughput and p99 processing time, in-flight messages, downstream saturation, and DLQ depth. Scale consumers only when parallelism is the bottleneck; otherwise fix the dependency, backpressure, schema, or configuration problem. Keep synchronous calls for results required by the caller, and use an outbox when a database commit must reliably produce a message.
Common Mistakes and Misconceptions
- Using a queue for request/response. If the caller needs the result to continue, synchronous RPC is usually simpler; queue the side effects instead.
- Assuming a queue means no duplicates or no loss. Delivery depends on broker durability, acknowledgement, retention, and configuration; at-least-once still repeats messages.
- ACKing before the side effect is durable. An early ACK can lose work; a late ACK can cause redelivery, so combine the boundary with idempotency.
- Setting visibility timeouts from the median. Tail latency matters. A short timeout creates concurrent redeliveries; a long timeout delays recovery after crashes.
- Treating the DLQ as a trash can. Inspect and alert on it, fix the cause, test one replay, then drain in controlled batches.
- Scaling consumers without checking the bottleneck. More workers do not fix a slow downstream, exhausted connection pool, bad schema, or wrong queue configuration.
- Choosing Kafka, RabbitMQ, or SQS by brand. Retention, replay, ordering, routing, throughput, and operational ownership should drive the choice.
Test Your Understanding
Quick Recap
- A message queue decouples producers from consumers using a broker β the producer publishes and returns after the required acknowledgement; the consumer processes asynchronously at its own pace.
- In a durable at-least-once setup, a consumer crash before ACKing can trigger redelivery after the visibility timeout. Idempotent consumers make that behavior safe for repeated delivery.
- Point-to-point queues assign a message to one active competing consumer at a time (work dispatch), while pub/sub topics fan out independent copies to subscribers (event broadcast) β choose based on the interaction.
- Kafka is a distributed append-only log built for high-throughput event streaming with replay; SQS is a fully managed simple queue for task dispatch β they solve different problems and are not interchangeable defaults.
- The Dead Letter Queue is a safety net for messages that exceed maximum retries β monitor its depth and age, inspect the cause, and replay only after the consumer or data issue is understood.
- Consumer group parallelism (SQS: worker instances; Kafka: partition count) is the lever for consumer throughput β but it cannot fix a bottleneck in a slow downstream call your consumers are making.
- A complete queue design names the delivery guarantee, visibility-timeout rationale, DLQ strategy, idempotency mechanism, and the metrics that show whether consumers are keeping up.
Related Concepts
- Microservices β Message queues are the most common mechanism for decoupling microservices in production. Understanding service boundaries helps calibrate when synchronous RPC vs. async queues is the right communication primitive for a given interaction.
- Event sourcing β Event sourcing treats every state change as an immutable event appended to a log β a natural architectural companion to Kafka. Understanding event sourcing explains why Kafka's log-based retention model is fundamentally different from a traditional task queue.
- Caching β Caches and queues both protect downstream services: caches absorb read fan-out, queues absorb write fan-out and decouple failure domains. Knowing when to reach for each is one of the most practical system design distinctions.
- Databases β The Transactional Outbox Pattern β writing events to a database outbox table atomically with your business data, then tailing that table into a queue β is a common way to reduce the lost-handoff window without a distributed transaction.
- Load balancing β Competing consumer patterns in queues achieve the same horizontal throughput scaling that load balancers achieve for HTTP traffic β both distribute work across homogeneous workers. Understanding both gives you the full picture of horizontal scaling across sync and async workloads.
Related Articles
Learn how microservices decompose monolithic applications into independently deployable services, when the operational overhead is worth it, and how to manage the distributed-systems failure modes that follow.
Learn how caching eliminates redundant database reads, which strategy to choose for your write pattern, and how to design a cache layer that survives invalidation at scale.
Learn how databases organize data for fast retrieval, which storage engine to choose for your workload, and how ACID transactions keep concurrent writes correct at scale.
Learn how load balancers distribute traffic across servers, which algorithms to choose, and how to design a highly-available app tier in any system design interview.