AI SWE Prep
0/9in System Design
System Design

Queues & Async Workflows

Decouple producers from consumers to absorb spikes and survive failures. Backpressure, retries, and idempotency.

~15 minLesson 50 of 60
Your progressNot started

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

Asynchronous work with a queue
  1. 1
    Producerenqueue job
  2. 2
    Queuebuffer + backpressure
  3. 3
    Workerconsume, retry on fail
  4. 4
    DLQpoison messages parked
A dead-letter queue catches messages that fail repeatedly so one bad job can't block the line.

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.

EstimationMedium
  • Backpressure
  • Capacity
  • Workers

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/sec

Current drain rate:

100 workers × 5 images/sec = 500 images/sec
backlog growth ~= 1,167 images/sec

Over 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 workers

A 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.

ScenarioHard
  • Idempotency
  • Delivery guarantees
  • Payments

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 guarded

Fix 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 message

The 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.

ScenarioMedium
  • DLQ
  • Retries
  • Operations

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_at

Operational flow:

  1. Alert on DLQ rate and age, not on every single failed attempt.
  2. Keep processing healthy messages; poison messages must not block the queue.
  3. Fix the consumer or data bug.
  4. 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.

Your progressNot started