Skip to content

    System Design

    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.

    By Published 14 min read

    Share

    This lesson is part of System Design Building Blocks: Data, Messaging and Scale. Enroll for free, then sign in, to read it in full.

    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.

    Publish/subscribe fan-out to work queuesOrder service to Topic (publish); Topic to Invoice queue; Topic to Warehouse queue; Topic to Email queue; Invoice queue to Invoice workers; Warehouse queue to Warehouse workers; Email queue to Email workersOrder service: emits OrderPlacedOrder serviceemits OrderPlacedTopicTopicInvoice queueInvoice queueWarehouse queueWarehousequeueEmail queueEmail queueInvoice workersInvoiceworkersWarehouse workersWarehouseworkersEmail workersEmail workerspublish

    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

    1. The consumer asks for a message.
    2. 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).
    3. The consumer works, then sends an ack. Only then is the message removed.
    4. 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.

    Photo upload as a work queueClient to API (1 upload); API to Client (4 202 Accepted); API to Object storage (2 store original); API to Queue (3 enqueue job); Queue to Thumbnail workers (5 receive); Thumbnail workers to Object storage (6 write thumbnails); Thumbnail workers to Database (7 update status); Queue to Dead-letter queue (after max retries)ClientClientAPIAPIQueueQueueThumbnail workersThumbnailworkersObject storageObject storageDead-letter queueDead-letterqueueDatabaseDatabase1 upload4 202 Accepted2 store original3 enqueue job5 receive6 write thumbnails7 update statusafter max retries

    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

    Order placement with three synchronous third-party callsCheckout page to Order service; Order service to Finance invoice API (sync call); Order service to Email provider (sync call); Order service to Warehouse partner API (sync call); Order service to PostgreSQLCheckout pageCheckout pageOrder serviceOrder serviceFinance invoice APIFinanceinvoice APIEmail provider: SMTPEmail providerSMTPWarehouse partner APIWarehousepartner APIPostgreSQLPostgreSQLsync callsync callsync call

    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.

    Enroll to keep reading

    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.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    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.

    Join engineers building production AI systems

    Comments