Networking & Delivery
Message Queues
Making a caller wait on a step they don't actually need the answer to, and why the fix trades that wait for a guarantee that a message might show up twice.
Networking & DeliveryIntermediate9 min read
After this lesson, you should be able to:
- Distinguish a synchronous call, caller blocked until downstream finishes, from a queued call, caller returns once durably enqueued.
- Explain why message queues default to at-least-once delivery, and what that requires from a consumer.
- Identify which kind of work belongs on a queue versus which genuinely needs a synchronous answer.
- Recognize that a downstream outage becomes a queue backlog instead of a caller-facing failure, and why that's the actual value bought.
At a Glance
- A slow downstream step, sending an email, calling a third-party API, doesn't need to happen before the caller gets a response, but a synchronous call makes them wait on it anyway.
- A queue decouples the two: the caller drops a message and moves on, a separate consumer processes it whenever it gets to it.
- The hard part isn't the decoupling. It's that queues are almost always at-least-once, so the same message can arrive twice, and the consumer has to handle that without doing the work twice.
01 The Problem
A signup handler creates the user row, calls a third-party email API to send a welcome email, then returns 201 to the client. The email call usually takes a couple hundred milliseconds. Sometimes it takes two seconds. Occasionally the provider has an outage and it times out completely, and now account creation, a thing entirely within your own system, fails because of a system you don't own and the client never needed an answer from.
Keeping it synchronous means every signup is exactly as slow and as fragile as the email provider's day. Firing the email from a background thread in the same process and returning immediately fixes the wait, but not the fragility: if the process crashes or restarts between creating the user and actually sending the email, that email silently never goes out, with no record it was even supposed to.
This is what a message queue actually fixes: the caller drops a durable record of "send this email" onto a queue and returns immediately, and a separate consumer, in its own process, picks it up and sends it, retrying if the email API is down, without the original request ever knowing or waiting.
02 What It Is
A message queue is a durable, intermediary store that decouples a producer, the code that has work to hand off, from a consumer, the code that does that work, so the producer can enqueue a message and move on without waiting for it to be processed. Configured for durability, persisted and replicated, a queue guarantees the message isn't lost once accepted, even if the consumer isn't running yet, or crashes partway through.
The part that trips people up isn't the decoupling, that's the easy, obvious win. It's the delivery guarantee. Almost every real queue promises at-least-once delivery, not exactly-once: if a consumer picks up a message, starts processing it, and crashes or times out before confirming it's done, the queue has no way to know the work actually finished, so it redelivers that same message, to the same consumer or a different one. Exactly-once delivery across a network isn't something you get for free, close to it, but not free, so the practical default is to assume any message might arrive twice and write the consumer so that's fine.
None of this requires exactly one consumer, either. Multiple consumer instances can subscribe to the same queue as a consumer group, and the queue makes sure each message goes to exactly one member of that group, not all of them. Add a second consumer to the group and processing capacity doubles without any message getting picked up twice by two different consumers at once, that's a separate guarantee from at-least-once delivery, not a substitute for it. A message can still be redelivered to whichever group member picks it up next if the first one fails, it just never goes to more than one of them at the same time. This is the actual mechanism behind scaling processing horizontally: add consumers to the group, and the queue spreads messages across them instead of any one consumer having to keep up alone.
In Plain Terms
Dropping a stack of dishes at a restaurant's dish pit doesn't require standing there watching until every plate is clean. You leave them and go back to your table. If the dishwasher gets pulled away mid-load and someone else has to finish the stack, from your side nothing changed, the dishes still get washed, you just have no idea whether it was one person or two who actually touched them. What matters to the kitchen is that "wash these" never gets forgotten, not that exactly one person did it start to finish.
03 How It Works
Rendering diagram…
That covers a consumer that dies mid-message. A more common case is a consumer that's perfectly healthy, acking normally, calling a downstream dependency that's having a bad day: the email API starts timing out or returning 500s. Retrying that call immediately, in a tight loop, doesn't help. It adds load to a system that's already struggling, and if every consumer in the group does the same thing at once, the retries themselves can be what keeps the downstream down. The fix is backing off between attempts, waiting longer after each failure instead of hammering the same endpoint every millisecond, so a struggling downstream gets room to recover instead of getting buried by the retries meant to work around it.
Backoff assumes the downstream is worth still calling, just more slowly. Past some point it isn't: a downstream that's been failing for the last hundred attempts in a row is not going to succeed on attempt a hundred and one just because you waited longer. That's the case for a circuit breaker: stop calling entirely for a while instead of calling less often, a different response to the same struggling downstream, not the same mechanism under a different name.
Backoff still has a ceiling. After enough failed attempts, the message stops being retried against the downstream and moves to a dead-letter queue instead, a separate holding place for messages that couldn't be processed. That's the difference between a DLQ and the two other options, retrying forever, which just burns resources on something that's already shown it isn't working, or dropping the message silently, which loses it with no record anything went wrong. A message sitting in a DLQ is inspected by hand: read the payload, figure out why it failed, a bad field, a downstream that's since recovered, fix whatever's fixable, and replay the message back onto the main queue to go through the normal path again.
Rendering diagram…
Try it: enqueue a message, click Process Next, then Crash Consumer before clicking Acknowledge, the message goes back to the queue instead of vanishing. Restart the consumer and process it again to see it complete normally. Then enqueue a poison message and click Process Next / Acknowledge three times in a row, it moves to the dead-letter queue once retries run out, and Replay from DLQ puts it back on the main queue.
Queue
0 messages
Consumer
Idle
Waiting for work
Dead-letter queue
0 messages
No messages yet.
04 Trade-offs
| Synchronous call | Queued (async) | |
|---|---|---|
| Caller's wait time | Blocked until the downstream step finishes, however long that takes | Returns as soon as the message is durably enqueued, independent of how long processing takes |
| Downstream outage impact | A downstream outage becomes your outage too, the caller's request fails | Messages pile up in the queue and get processed once the downstream recovers |
| Delivery guarantee | Exactly what actually ran, once, because the caller waited to see it happen | At-least-once by default; the consumer must expect and handle redelivery |
| Best for | Work the caller genuinely needs the result of before responding | Work the caller doesn't need to wait on: notifications, emails, anything retriable after the fact |
Watch out
It's tempting to hear "message queue" and assume exactly-once, each message processed a single time, because that's what the intuitive picture of a queue suggests, an item pulled off once and gone. Almost no real queue actually guarantees that end to end, because doing so means coordinating the consumer's processing with the queue's acknowledgment in a single atomic step across a network, expensive enough that most systems don't pay for it. The honest default is at-least-once, and a consumer that isn't written to handle a duplicate message isn't using the queue correctly, whatever the queue calls itself.
05 Where You'll Actually See This
Neither case study uses a queue directly, but the payment gateway case study's asynchronous webhook, the processor calling back after it settles, solves a related problem from the opposite side: the processor doesn't want to block on notifying you, so it decouples that call the same way a queue decouples a producer from a consumer, just without a durable intermediary store sitting in between.
The real overlap is with idempotency keys. A queue consumer that isn't written to handle redelivery processes the same message twice, charges a card twice, sends a welcome email twice, exactly the failure mode idempotency keys exist to prevent. It's the same problem from a different entry point: idempotency keys protect a synchronous API request from being retried by a client, and the same defense protects a queue consumer from being retried by the queue itself.
Rate limiting covers the proactive version of the backoff problem above. A consumer that knows its downstream's limit upfront can throttle its own outbound calls with a token bucket, instead of finding that limit the hard way, by getting rejected and backing off after the fact.
Everything on this page has been one producer and one consumer. Event-driven architecture is what happens when one message needs many independent consumers instead of one, usually built on this exact same queue mechanism underneath.
Everything here has also assumed the message actually gets enqueued. A producer that writes to its own database and then enqueues a message as two separate steps can crash between them, or have the enqueue call fail, the same dual-write gap the outbox pattern exists to close.
Distributed tracing's trace id has an easy time crossing a synchronous call, one HTTP header, forwarded automatically by most instrumentation. It has no such easy time crossing a queue. There's no request to attach a header to, the producer has to write the trace id into the message's own metadata, and the consumer has to read it back out and continue the same trace explicitly. Skip that step, which is easy to skip since nothing errors if you do, and the trace just ends at the producer, same silent gap as a dropped header anywhere else.
How to reason about it
When asked why you'd put a queue in front of something, don't just say "so it's async." Say specifically what the caller no longer has to wait on, and say how the consumer handles getting the same message twice, because at-least-once is the default, not an edge case. An answer that doesn't mention redelivery has described the convenient half of message queues, not the part that actually has to work in production.
06 Quick Check
A consumer successfully sends a welcome email, but crashes before it can acknowledge the message back to the queue. What happens next, and what does that mean for how the consumer needs to be written?