Queues, streams and asynchronous work
How to take slow or bursty work off the request path, and what delivery guarantees really mean.
On the roadmap:Intermediate · step 4: Messaging
Lesson 8 of 13 · 11 min read · quiz · exercise
Not everything has to happen now
A user clicks "Sign up". Creating the account must happen before you reply. Sending the welcome email, resizing the avatar and updating dashboards do not. Doing them inside the request makes it slow and fragile: if the email service hiccups, signup fails.
Image: W.carter · CC0 · Source · Wikimedia Commons: People waiting in line at Stena Line ferry terminal, Frederikshavn
Asynchronous processing moves such work out of the request. The request records what must happen and returns; other workers do it shortly after.
Figure SDA-FND-08-1 · Asynchronous work via a queue · © forcsguy.com
What a queue gives you
- Decoupling: the producer does not know or care who consumes.
- Load levelling: a spike of 10,000 jobs is stored and worked off at a steady rate instead of crashing the consumer.
- Resilience: if a consumer is down, messages wait.
- Independent scaling: add more workers when the queue grows.
The price is eventual completion: the work is done soon, not now, and the system has more moving parts.
Before: everything in the request
After: slow work goes through a queue
What changed
- The user gets a reply straight away
- A slow email service no longer breaks signup
- Spikes pile up in the queue instead of crashing servers
Queues versus streams
- A message queue (RabbitMQ, SQS) delivers each message to one consumer, which then removes it. Great for task distribution.
- A log or stream (Kafka, Kinesis) keeps messages in order for a retention period. Many independent consumers can read it, and each can replay from any point. Great for event pipelines and for feeding several systems from one source of truth.
Delivery guarantees
Networks and consumers fail, so a broker cannot give every guarantee for free.
| Guarantee | Meaning | Risk |
|---|---|---|
| At-most-once | Delivered zero or one time | May lose messages |
| At-least-once | Delivered one or more times | May duplicate messages |
| Exactly-once | Effect happens exactly once | Costly, and usually achieved by at-least-once plus idempotency |
The practical approach: use at-least-once delivery and make handlers idempotent.
Figure SDA-FND-08-2 · Why the moment of acknowledgement decides the guarantee · © forcsguy.com
Idempotency in practice
Make repeating an operation harmless:
- Prefer "set the order status to paid" over "add 10 to the balance".
- Attach a unique idempotency key to a request and remember the keys already processed.
- Use database constraints (a unique key) so a duplicate insert fails safely.
Failure handling
- Retries with backoff: wait longer between attempts (1 s, 2 s, 4 s...) with a little random jitter so many consumers do not retry in lockstep.
- Dead-letter queue: after N failures, move the message aside so it cannot block the rest, and alert a human.
- Ordering: strict global order is expensive. Ordering is usually guaranteed only within a partition, so pick a key (like user id) so related events land in the same one.
- Back pressure: if consumers fall behind, the queue grows. Monitor its depth and age, and scale workers or slow producers.
A worked example: sizing workers and watching the backlog
A photo app creates thumbnails in the background. Each job takes 2 seconds of one worker. Normal traffic is 50 uploads per second.
- Workers needed at the average: 50 jobs/s x 2 s = 100 workers busy all the time. Plan for about 70% utilisation, so roughly 143 workers, which can do about 71 jobs a second.
- A spike: a campaign sends 500 uploads per second for 60 seconds: 30,000 jobs. The queue accepts them instantly; the workers keep draining at about 71 per second. The backlog grows by 500 - 71 = 429 per second, to about 25,700 jobs by the end of the spike.
- Recovery: after the spike, arrivals fall back to 50 per second and the workers drain a net 71 - 50 = 21 per second. Clearing 25,700 jobs takes about 1,200 seconds, 20 minutes. That is the real cost of a spike: not an outage, but a delay.
This is why the metric to watch is queue age (how long the oldest message has waited), not just depth. Depth of 25,000 can be fine for a thumbnail and disastrous for a payment confirmation. Decide how stale each job may become, and alert on that.
Figure SDA-FND-08-3 · A queue turns a spike into a delay · © forcsguy.com
What happens to a message that keeps failing
Workers fail: a bug, a missing record, a dependency down. Without a plan, one bad message can block everything or loop forever.
- A worker receives a message. The queue hides it for a visibility timeout (say 60 seconds) instead of deleting it.
- If the worker finishes, it acknowledges and the message is deleted.
- If the worker crashes or fails, no acknowledgement arrives, the timeout expires and the message becomes visible again for another try.
- Retries use exponential backoff with jitter so a struggling dependency is not hammered.
- After a fixed number of attempts (often 3 to 10) the message moves to a dead-letter queue so it stops blocking others. A person or a tool inspects it, fixes the cause, and replays it.
Figure SDA-FND-08-4 · Retries and the dead-letter queue · © forcsguy.com
Because a message can be delivered more than once, every consumer must be idempotent: processing it twice must have the same effect as once. Typical tools are a unique message id recorded in a table (insert first, skip on conflict), or operations that are naturally repeatable ("set status to shipped" rather than "add one").
Mistakes people make
- Non-idempotent consumers. "Charge the card" run twice is a double charge. Use an idempotency key.
- Unbounded retries. A permanently failing message that retries forever wastes capacity. Cap attempts and dead-letter.
- Ignoring queue age. Alert on how old the oldest message is, per queue.
- One queue for everything. A flood of low-priority jobs starves urgent ones. Separate queues, or priorities, by importance.
- Assuming order. Most queues do not guarantee order across consumers. If order matters per user or per order, partition by that key.
- Huge messages. Put big payloads in object storage and send a reference.
- Using a queue when the caller needs the answer now. Queues make work asynchronous; if the user is waiting for the result, they only add delay.
Try it yourself
In the System Builder, open the challenge "A burst of writes": run it without a queue and then with one, and watch the "Writes waiting in queue" chart. For a database-backed version with retries and a dead-letter state, read the SKIP LOCKED queue lesson.
Related reading
- Confluent Developer: Apache Kafka 101ConfluentCourses
- Confluent: Apache Kafka TalksConfluentYouTube
- Making retries safe with idempotent APIsAmazon Builders' Library · Amazon Web ServicesBlogs
- Apache Kafka DocumentationApache Kafka · Apache Software FoundationDocumentation
Show 6 moreShow fewer
- Pattern: Transactional outboxChris Richardson · microservices.ioDocumentation
- Confluent BlogConfluent engineers · ConfluentBlogs
- Scaling Slack's Job QueueSlack Engineering · SlackCase Studies
- Kafka on PaaSTA: Running Kafka on Kubernetes at Yelp (2 parts)Yelp engineers · YelpCase Studies
- Kafka on Kubernetes at ShopifyShopify engineers · ShopifyCase Studies
- Brooklin: Distributed Service for Near Real-Time Data Streaming at LinkedInLinkedIn engineers · LinkedInCase Studies
Check your understanding
Question 1 of 4
0 correct so far
What is the main benefit of putting a message queue between a producer and a consumer?
Go deeper
- Designing Data-Intensive Applications, chapters 11 and 12 by Martin Kleppmann: stream processing and the future of data systems
Topics covered in this lesson
Practise it: Absorb a burst with a queue
Everything below is already set up for this lesson: change the design, run the traffic, and see whether you meet the goal.
- 1The task
- 2Build and test
A bulk import sends 1,500 requests per second, most of them writes. The database cannot keep up in real time.
Goal: Under 1% errors, and the queue clears within 30 seconds of work.
Callers do not need their write applied this very second. What can hold it for them?
Try it yourself
When a user signs up, the app must create the account, send a welcome email and add them to an analytics system. Today it does all three inside the request and signup sometimes takes 8 seconds. Redesign it, and explain what you do if the email service is down.
Click what a strong design here would need:
Next course
© 2026 forcsguy.com. Original lesson text and diagrams. Books and sites linked above belong to their authors.