Queues & Async Workflows
Decouple producers from consumers to absorb spikes and survive failures. Backpressure, retries, and idempotency.
Not everything should happen inside the user’s request. When a user uploads a video, they shouldn’t wait for it to transcode; when they sign up, they shouldn’t wait for the welcome email to send. A message queue lets you accept the work now and do it later — decoupling the fast path from the slow path.
The core idea: decoupling producers and consumers
A queue sits between a producer (which puts messages in) and one or more consumers (which take them out and process them). The producer doesn’t know or care who processes the message, or when. This buys you three big properties:
- Responsiveness — return to the user immediately after enqueuing.
- Load smoothing — a traffic spike fills the queue instead of overwhelming your workers; they drain it at their own pace.
- Resilience — if a consumer crashes, the message is still in the queue and gets retried, rather than lost.
# Producer — inside the web request, returns instantly
queue.publish("emails", {"to": user.email, "template": "welcome"})
return {"status": "accepted"} # HTTP 202
# Consumer — a separate worker process
for msg in queue.consume("emails"):
send_email(msg["to"], msg["template"])
msg.ack() # acknowledge only after success
Queues vs. streams
- 1Producerenqueue job
- 2Queuebuffer + backpressure
- 3Workerconsume, retry on fail
- 4DLQpoison messages parked
Two related shapes you should distinguish:
- Task queues (RabbitMQ, SQS, Celery) — a message is a unit of work handed to one consumer, then removed. Think “process this job.”
- Event streams / logs (Kafka, Kinesis, Pulsar) — an append-only log that many consumers read independently, each tracking its own position. Think “here is a firehose of events; subscribe to it.” Great for analytics, audit logs, and fanning one event out to several systems.
Delivery guarantees and idempotency
Distributed messaging can’t perfectly promise “exactly once.” Most systems give you at-least-once delivery — a message may be delivered more than once (e.g. a consumer processed it but crashed before acknowledging). The practical consequence is a rule you must internalize:
Design consumers to be idempotent — processing the same message twice has the same effect as processing it once.
You achieve idempotency by keying on a unique message ID and skipping work you’ve already done, or by making operations naturally repeatable (“set status = paid” rather than “add $10”).
Backpressure and dead letters
- Backpressure — when producers outrun consumers, the queue grows. You need a plan: scale up consumers, shed load, or cap the queue. A silently unbounded queue is a latent outage.
- Dead-letter queue (DLQ) — a message that keeps failing shouldn’t retry forever. After N attempts, route it to a DLQ for inspection so one poison message can’t block the pipeline.
When to introduce a queue
Reach for async processing when work is slow (media processing, LLM calls), spiky (bursts you want to absorb), failure-prone (third-party APIs you’ll want to retry), or simply not needed for the response (emails, analytics, webhooks). If the user genuinely needs the result now, keep it synchronous — adding a queue where you need an immediate answer just adds latency and complexity.
Pattern recognition
Variations
Worked problems
Queue designs are capacity designs. State arrival rate, drain rate, retry policy, and idempotency before naming a queue product.
Absorb an image-processing spike
A photo app receives 2 million new images during a 20-minute event. Each image needs thumbnail generation. One worker can process 5 images/sec, and you normally run 100 workers. How does the queue behave, and what capacity do you need to keep up with the spike?
Approach. Compare arrival rate to worker drain rate. If arrivals exceed drain, the difference becomes backlog. Decide whether to scale workers or accept delayed processing.
Show solution
Arrival rate:
2,000,000 images / 20 minutes
20 minutes = 1,200 seconds
arrival ~= 1,667 images/secCurrent drain rate:
100 workers × 5 images/sec = 500 images/sec
backlog growth ~= 1,167 images/secOver the event, backlog grows by about 1,167 × 1,200 ~= 1.4M jobs. That may be fine if thumbnails can appear later, but you should say so explicitly.
To keep up in real time:
1,667 images/sec / 5 per worker ~= 334 workers
Add headroom: ~400 workersA good design accepts uploads quickly, stores originals durably, enqueues work, and exposes processing status. If the queue depth crosses an SLO threshold, scale workers, reduce thumbnail variants, or degrade by generating only the smallest thumbnail first.
Exactly-once is the wrong promise
A payment provider sends webhooks for successful charges. Your consumer sometimes processes the same webhook twice because it crashes after updating the database but before acknowledging the message. How do you prevent double fulfillment?
Approach. Assume at-least-once delivery and make fulfillment idempotent. The ack should happen only after durable work, and duplicate messages should become no-ops.
Show solution
The failure sequence:
receive event payment_succeeded:evt_123
create fulfillment row
crash before ack
queue redelivers evt_123
consumer creates fulfillment again unless guardedFix it with a unique event or operation key:
begin transaction
insert processed_event(evt_123) # unique key
if insert conflicts:
ack message; return
mark order paid
create fulfillment if not exists for order_id
commit
ack messageThe consumer can now run twice with the same final effect. This is the practical version of “exactly once”: at-least-once delivery plus idempotent side effects.
Also make the state transition idempotent: set order.status = paid is safer than add one paid order count unless the counter update is deduplicated too.
Design dead-letter handling
A queue consumer retries failed jobs forever. A bad deploy introduces a schema bug for 1% of messages. Those messages keep failing, filling logs and delaying good work. Design the retry and dead-letter behavior.
Approach. Separate transient failures from poison messages. Retry with bounded backoff, then move exhausted messages to a DLQ with enough context to fix and replay them.
Show solution
A sane policy:
attempt 1: immediate retry for obvious transient failure
attempt 2-5: exponential backoff with jitter
attempt >5: move to DLQ with error type, stack, payload pointer, first_seen_atOperational flow:
- Alert on DLQ rate and age, not on every single failed attempt.
- Keep processing healthy messages; poison messages must not block the queue.
- Fix the consumer or data bug.
- Replay DLQ messages in a controlled batch with the same idempotency guards.
The DLQ is not a trash can. It is a quarantine with enough metadata to decide: retry later, repair payload, ignore safely, or escalate to a human.