System Design: Message Queues and Asynchronous Work
Learn how queues decouple services, how acknowledgements, retries and dead-letter queues work, and what delivery guarantees really mean, including idempotent consumers.
What you'll learn: By the end of this lesson you will be able to decide when work should be asynchronous, explain how acknowledgements, visibility timeouts, retries and dead-letter queues keep messages from being lost, state delivery semantics precisely and design idempotent consumers, and apply back-pressure so a fast producer cannot flatten a slow consumer.
Think of a restaurant kitchen. A waiter takes an order and clips a paper ticket onto a rail. Cooks pick tickets off the rail when they are free. The waiter does not stand at the stove waiting for the dish, and the kitchen does not need to know which waiter wrote the ticket. If the dinner rush arrives, the rail fills up and the cooks work through it as fast as they can. If a cook falls ill, another takes the ticket.
That rail is a message queue: a durable buffer between the part of a system that creates work and the part that does it. It lets a request return in milliseconds even when the work behind it takes seconds, absorbs bursts, and lets each side fail and scale independently. It also introduces problems a direct call never had: duplicates, reordering and messages that never succeed. This lesson covers the benefits and the discipline needed to use queues safely.
The core idea
A queue turns a call into a promise. Instead of "do this now and tell me the result", the producer says "here is a message describing work; someone will do it". The producer gets a fast acknowledgement that the message was safely stored, and a consumer does the work later. That changes timing (neither side needs to be up or fast at the same moment), coupling (the producer knows only the queue) and failure (a crashed consumer leaves the message in the queue to be tried again). The price: results are not immediate, messages can arrive more than once, and you now operate another piece of infrastructure.
Why go asynchronous?
Do synchronously whatever the user needs to see before the response, and queue everything else. Good candidates: email, SMS and push; thumbnails, transcoding and PDF reports; slow or unreliable third-party calls; updating search indexes, analytics and caches after a write; and smoothing bursts. Poor candidates: reads the user is waiting for, checks that must pass before an action is allowed (such as "is the balance sufficient"), and anything where "accepted" might later turn into a silent failure.
Question
If yes
Cost
Alternative
Does the user need the result in this response?
Do it synchronously
Slower response
Return a job id and let the client poll
Can the work outlast a request timeout?
Queue it
Result arrives later; status tracking
Longer timeouts (fragile)
Is a downstream system slower than peak traffic?
Queue to smooth bursts
Added latency under load
Rate limiting upstream
Must the work survive a crash of the caller?
Queue (durable)
Another system to run
Database table used as a job list
Queue versus publish/subscribe
A queue (point-to-point, work queue) hands each message to exactly one consumer in a group; consumers compete, which spreads load. Use it for tasks: "resize this image". Publish/subscribe (topic) delivers each message to every subscriber. Use it for events: "order 123 was placed", which billing, shipping and email each react to. Real systems combine them: a topic fans an event out to several subscriptions, and each subscription is a queue shared by a group of workers.
Each queue gets its own copy of the event, and inside a queue the workers compete, so adding workers scales one concern without touching the others.
The life of a message: acknowledgements and visibility
The consumer asks for a message.
The queue hands it over but does not delete it. It is "in flight" and hidden from other consumers for the visibility timeout (a lease or ack deadline, depending on the product; some brokers instead track the open consumer connection).
The consumer works, then sends an ack. Only then is the message removed.
If the consumer crashes or exceeds the timeout, the message becomes available again.
sequenceDiagram
participant P as Producer
participant Q as Queue
participant C as Consumer
P->>Q: send message
Q-->>P: stored
C->>Q: receive
Q-->>C: message, hidden for timeout
C->>C: process
alt success
C->>Q: ack
Note over Q: message deleted
else crash or timeout
Note over Q: message visible again
Q-->>C: redelivered to a consumer
end
If the timeout is shorter than real processing time, a second consumer receives the same message while the first still works: duplicate work. If it is far longer, a crashed consumer's message sits hidden for ages. Set it comfortably above normal processing time, and for long jobs extend the lease periodically.
Retries and dead-letter queues
A transient failure (a downstream service was briefly down) succeeds on retry. A poison message (malformed data, a bug triggered by one input) fails every time and, retried forever, burns capacity and floods logs. The standard design: retry a limited number of times with exponential backoff and jitter (see reliability basics); after the maximum attempts move the message to a dead-letter queue (DLQ); alert on DLQ depth, because an unwatched DLQ is where data goes to be forgotten; and build a redrive tool to push messages back once the bug is fixed.
flowchart TD
M["Message received"] --> H{"Handler result"}
H -->|"success"| Ack["Ack and delete"]
H -->|"validation error"| DLQ["Dead-letter queue now"]
H -->|"transient error"| R{"Attempts left?"}
R -->|"yes"| Back["Retry after backoff with jitter"]
R -->|"no"| DLQ
Back --> M
DLQ --> Alert["Alert and redrive after the fix"]
Validation errors go straight to the DLQ because retrying cannot help; timeouts and 5xx responses deserve backoff and retry.
Delivery semantics, stated precisely
At-most-once. Zero or one delivery. The queue marks it delivered before the consumer finishes; a crash loses the message. Acceptable for disposable data such as a non-critical metric sample.
At-least-once. One or more deliveries. The message is removed only after an ack, so a crash or lost ack causes redelivery. This is the default of most practical queues and the model to assume.
Exactly-once. Be careful with this phrase. Over an unreliable network a sender cannot tell whether a missing acknowledgement means the message was not received or the ack was dropped, so "exactly-once delivery" between independent systems is not achievable in general. What is achievable is exactly-once effect: at-least-once delivery plus idempotent processing, or transactional processing inside one system where reading input, updating state and writing output commit atomically. The latter does not extend automatically to an email provider or payment gateway on the other side.
Semantics
Mechanism
Failure result
Consumer requirement
At-most-once
Ack before processing
Possible loss
None, accept loss
At-least-once
Ack after processing
Possible duplicates
Must be idempotent
Exactly-once effect
At-least-once plus idempotency, or atomic transaction
Duplicates harmless
Dedup key or transactional sink
Idempotent consumers
An operation is idempotent if doing it twice has the same effect as once. "Set order 123 to shipped" is; "add 10 to the balance" is not. Techniques: natural idempotency (set, do not increment); a deduplication key assigned by the producer (not generated per send attempt), recorded by the consumer in the same database transaction as the business effect; conditional writes with unique constraints; and passing the id downstream as an idempotency key to external services, as in API design.
begin transaction
if exists(processed_ids, message.id): commit; ack; return
apply business change
insert into processed_ids(message.id)
commit
ack
If the consumer crashes after the commit but before the ack, the redelivered message finds its id and is skipped.
Ordering
A single queue with a single consumer processes in order but caps throughput at one consumer. With competing consumers, completion order can differ from arrival order, and retries make it worse: a failed message returns after newer ones were processed. If you need order only per entity (one order, one account), route all that entity's messages to the same consumer via a partition key or message group; different entities still run in parallel, usually at some throughput cost. Strict global order is a throughput ceiling; question whether you need it. A sturdier approach is tolerance: carry a version number and ignore messages older than what the entity has already seen.
WARNING
"FIFO queue" does not mean "my consumers process in order". Parallel consumers, redelivery and retries can reorder processing. Read your broker's documentation for what its ordering guarantee covers.
Back-pressure: protecting the slow side
A queue hides a speed mismatch but does not fix it. If producers add 1,000 messages per second and consumers finish 600, the queue grows by 400 per second; for illustration, after an hour that is 1.44 million waiting messages, each getting older. Back-pressure pushes the problem back toward the producer or sheds load before things break: alert on queue depth and age of the oldest message (age measures how late work is), autoscale consumers up to what downstream systems can handle, bound the queue and make producers reject or degrade when full, rate limit at the entry point (rate limiting), give messages a time-to-live so stale jobs are dropped, and limit concurrency per downstream so more consumers do not just overload the database or API they call.
The work-queue pattern in one picture is photo upload: the API stores the original in object storage, enqueues {photoId, objectKey}, returns 202 Accepted; workers make thumbnails, update status and ack; bad images end in the DLQ.
One subtle pitfall is the dual write: saving to the database and publishing to the queue are two operations, and a crash between them leaves the system inconsistent. The usual fix is the transactional outbox, covered in distributed transactions.
In practice: order fulfilment with invoice, email and warehouse messages
NOTE
A realistic scenario, not a case history. This is a composite example built from common production patterns. It does not describe any specific company, and every number in it is an illustrative assumption.
The situation
A home-goods retailer ships roughly 30,000 orders a day, and about 3 times that during seasonal sales. The order service is a Java application on Kubernetes with PostgreSQL. On "order placed" it does, inside the HTTP request: create the invoice via a finance API, send a confirmation email through an SMTP provider, and push a pick request to a warehouse partner's HTTP API. A team of nine owns all of it.
The metrics that hurt (illustrative): checkout p99 is 6 seconds on sale days because three downstream calls run in line; when the warehouse API is down, checkout returns errors even though the order and payment succeeded; and a retry bug sometimes sent customers two confirmation emails.
Starting architecture
Checkout latency is the sum of three third-party calls, and its availability is their product.
Options considered
Option
What it fixes
What it costs
Risk
A. Keep sync calls, add timeouts and retries
Shorter hangs
Little
Retries in the request path still block checkout and still duplicate
B. Outbox table in PostgreSQL plus a relay publishing to RabbitMQ; one queue per concern with workers, DLQs and idempotent consumers
Checkout returns after one local transaction; each concern fails and scales alone
A broker to operate; async status tracking
Duplicates and ordering must be handled
C. Poll an outbox table with workers and no broker
Fewer moving parts; at about 0.35 orders per second on average (30,000 a day) and perhaps 10 per second at a sale peak, it would work
Retry, backoff, DLQ, redrive and per-queue dashboards must be built by hand; polling load
Reinvents queue features; fan-out to new consumers is awkward
The decision
ADR field
Content
Context
Checkout must not depend on three external systems; order data is the source of truth. Volume is low enough that Option C would also work; B is chosen for ready-made retry and DLQ tooling and easy addition of consumers.
Decision
Option B. The order service writes the order and an outbox row in one transaction; a relay publishes OrderPlaced to a RabbitMQ topic exchange bound to invoice, email and warehouse queues (quorum queues). Consumers ack after the effect, use the order id as dedup key, and pass it as an idempotency key to the finance API and warehouse API. After 5 attempts with backoff and jitter, messages go to a per-queue DLQ.
Consequences
Good: checkout p99 depends only on PostgreSQL; a warehouse outage delays picks, not orders. Bad: the confirmation email is eventual; the team must run and monitor a broker, DLQs and a redrive tool.
Revisit when
Sustained throughput exceeds what the broker cluster handles with headroom, or other teams need replay of order history, which points to a log (see the event streaming lesson).
Rollout and verification
Ship the outbox and relay first with no consumers reading, and verify rows publish. Then enable the email consumer in shadow mode (log only), then live for 5 percent of orders behind a flag, then the invoice and warehouse consumers. Run a load test at 3x sale volume including a killed consumer pod and a paused warehouse stub. Dashboards: oldest-message age per queue, queue depth, publish-to-ack latency, DLQ depth, redelivery rate, duplicate-suppressed count from the dedup table, and checkout p99. The target: age of the oldest message in each queue stays below its SLO (say 2 minutes for email, 15 for warehouse) at peak.
Outbox relay publishing to RabbitMQ queuesOrder service to PostgreSQL (one transaction); PostgreSQL to Outbox relay; Outbox relay to RabbitMQ; RabbitMQ to Invoice queue; RabbitMQ to Email queue; RabbitMQ to Warehouse queue; Invoice queue to Invoice DLQ (after 5 attempts); Warehouse queue to Warehouse DLQ (after 5 attempts)Order serviceOrder servicePostgreSQL: outbox tablePostgreSQLoutbox tableOutbox relayOutbox relayRabbitMQ: topic exchangeRabbitMQtopic exchangeInvoice queueInvoice queueEmail queueEmail queueWarehouse queueWarehousequeueInvoice DLQInvoice DLQWarehouse DLQWarehouse DLQone transactionafter 5 attemptsafter 5 attempts
The 2 a.m. failure
The warehouse partner's API starts returning 503. The page reads: "warehouse queue oldest message age above 15 minutes". Messages are retried with backoff, depth grows, and with 5 quick attempts a burst of orders would have landed in the DLQ even though the outage is transient.
sequenceDiagram
participant Q as Warehouse queue
participant W as Worker
participant P as Partner API
Q->>W: deliver order 9001
W->>P: create pick request
P-->>W: 503
W->>Q: reject for delayed retry
Note over Q: age rises, depth grows
W->>W: circuit opens, pause consumption
The first three checks: rabbitmqctl list_queues name messages_ready messages_unacknowledged to see depth and in-flight counts; the worker logs and error-rate panel to confirm the 503s are the partner's; and the DLQ depth, to see whether retries are being exhausted. Mitigation: pause the consumer (or open its circuit breaker) so messages stay safely in the queue rather than burning their attempts, notify the partner, redrive anything already dead-lettered once the partner recovers, and resume with limited concurrency. Follow-up fix: retry counts should be per time window, not just per attempt, so a long dependency outage never exhausts attempts; add an explicit "dependency down" path that holds messages.
Architect's notes
Alert on age first, depth second. A deep queue draining fast is fine; a shallow queue whose oldest message is old is not. Define a freshness SLO per queue.
Ask who owns each DLQ. If the answer is "the platform team", no one will read it. Each DLQ needs an owning team, an alert and a weekly review.
Separate transient from permanent errors in code review. The classification in the handler is the single biggest factor in whether retries help or hurt.
Check the idempotency boundary of every external call. If a vendor offers no idempotency key, a duplicate effect is possible and needs a compensating check.
Count the broker in the availability budget. Moving work off the request path raises checkout availability but adds a dependency; keep the outbox so publishing can lag without losing orders.
Defer fancy ordering. Start with per-order partitioning only where a real ordering bug exists; global FIFO is rarely worth its throughput ceiling.
Common mistakes
Assuming exactly-once delivery. Duplicates will happen. Make consumers idempotent and use dedup keys.
Retrying forever. One poison message can burn capacity indefinitely. Cap attempts, back off with jitter, dead-letter.
Ignoring the DLQ. Messages pile up unseen and customers lose emails or orders. Alert on depth and build a redrive tool.
Visibility timeout shorter than processing time. The queue hands the message to a second worker. Set it above the longest normal run or extend the lease.
Assuming FIFO means ordered processing. Partition by entity key and make handlers tolerate stale messages.
Unbounded queues without alerts. A growing backlog hides a capacity problem until it is an outage. Alert on oldest age.
Using a queue for work the user is waiting on. Provide status tracking or do that step synchronously.
Key takeaways
A queue decouples time, scale and failure. Producers get a fast acknowledgement; consumers work later and independently.
Queue for tasks, topic for events. Point-to-point shares work; publish/subscribe copies each event to every subscriber.
Ack after the work is done. Acknowledgements and visibility timeouts stop a crash from losing a message.
Assume at-least-once. Exactly-once delivery between independent systems is not achievable; build exactly-once effect with idempotent consumers.
Cap retries and keep a dead-letter queue. Backoff with jitter handles transient errors; a monitored DLQ catches poison messages.
Ordering is per key, not global. Partition by entity to keep related messages in sequence while preserving parallelism.
Watch the age of the oldest message. It is the clearest signal that consumers cannot keep up.
Back-pressure is a design requirement. Bound queues, limit concurrency, scale consumers and shed stale work.
Practice exercises
Exercise 1 — Starter (30 min): List eight operations in an online store (checkout payment, order confirmation email, inventory decrement, invoice PDF, recommendation update, fraud check, shipping label, analytics event). Mark each synchronous or asynchronous with a one-line reason, and say which would use a queue and which a topic.
Exercise 2 — Intermediate (2 hours): Write the pseudocode for an idempotent email-sending consumer, including the deduplication table, what is committed together, the order of "send", "record" and "ack", and what happens if the process crashes between each pair of steps. Identify the step where a duplicate email could still occur and explain what an idempotency key at the email provider would change.
Exercise 3 — Advanced (half day): Design the order-processing pipeline for a flash sale receiving about 20,000 orders per second for ten minutes (illustrative) while the payment provider handles at most 2,000 per second. Specify the queue topology, partitioning key, consumer concurrency, retry and DLQ policy, how users learn the outcome, when you reject new orders, and the dashboard and alerts. Draw it with Mermaid or on paper.
Lesson 6 of 14 · System Design Building Blocks: Data, Messaging and Scale
Learn how queues decouple services, how acknowledgements, retries and dead-letter queues work, and what delivery guarantees really mean, including idempotent consumers.
In the rest of this lesson
Delivery semantics, stated precisely
Ordering
Back-pressure: protecting the slow side
In practice: order fulfilment with invoice, email and warehouse messages
Architect's notes
Common mistakes
Enrolling is free. It unlocks every lesson in System Design Building Blocks: Data, Messaging and Scale, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.
Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.
Get new articles by email
Free. We email a confirmation link first, and you can unsubscribe from any email.