Message queues and background jobs
Move slow work out of the request, decouple services, and process work reliably with queues.
12 min read
You will learn
- Explain why slow work shouldn't run inside a web request
- Describe queues, producers, consumers and acknowledgements
- Compare point-to-point queues with publish/subscribe
- Handle retries, dead-letter queues and idempotent consumers
Don't make the user wait
When a customer places an order, several things should happen: save the order, charge the card, send a confirmation email, update analytics, notify the warehouse, generate an invoice PDF. Doing all of that inside the HTTP request makes it slow and fragile — if the email server is down, should the order fail?
The answer is to do only what's essential now, and hand the rest to background processing via a queue.
POST /orders enqueue dequeue
Client ────────────────▶ API ─────────────────────▶ [ Queue ] ─────────────▶ Worker
◀──── 202 Accepted ── ▲ ▲ ▲ (sends email,
(or 201 + order id) buffered makes PDF…)Queues: producers and consumers
A message queue (RabbitMQ, Azure Service Bus, Amazon SQS, Kafka, or Redis streams) stores messages until a consumer takes them:
- The producer (your API) publishes a message — e.g.
{ "orderId": 42 }— and moves on. - One or more consumers (workers) receive messages, process them, then acknowledge them.
- If a worker crashes before acknowledging, the message becomes visible again and another worker retries it.
Benefits:
- Responsiveness — requests return immediately.
- Decoupling — the API doesn't need the email service to be up right now.
- Load leveling — a burst of 10,000 orders queues up and workers process them at a steady pace.
- Scalability — add more workers to drain the queue faster.
Queue vs publish/subscribe
| Point-to-point queue | Publish/subscribe (topics) | |
|---|---|---|
| Meaning | "Do this job" — a command | "This happened" — an event |
| Delivery | Each message to one consumer | Each message to every subscriber |
| Example | SendInvoiceEmail | OrderPlaced → email service, warehouse, analytics each react |
Events let new features subscribe to existing events without changing the producer — a key idea in event-driven architecture.
Delivery guarantees and idempotency
Networks and processes fail, so brokers typically guarantee at-least-once delivery: a message may arrive more than once. Consumers must therefore be idempotent:
async Task HandleAsync(OrderPlaced message)
{
if (await db.ProcessedMessages.AnyAsync(m => m.Id == message.MessageId))
return; // already handled — skip
await emailSender.SendConfirmationAsync(message.OrderId);
db.ProcessedMessages.Add(new ProcessedMessage(message.MessageId));
await db.SaveChangesAsync();
}Retries and dead letters
Transient failures (a timeout, a deadlock) deserve a retry — usually with a delay that grows each time. But a poison message that always fails (malformed data, a bug) would be retried forever and block others. After N attempts, brokers move it to a dead-letter queue for humans to inspect and replay.
The dual-write problem and the outbox
Saving an order to the database and publishing a message are two separate operations. If the app crashes between them, you get an order without an event (or an event without an order). The transactional outbox pattern fixes this: write the message to an outbox table in the same database transaction as the order, and let a background process publish outbox rows to the broker.
Background work in .NET
BackgroundService/IHostedService— long-running loops inside your app (e.g. consuming a queue, cleaning up expired data).Channel<T>— an in-process queue between API and background service (Track 6) — simple, but lost on restart.- Hangfire, Quartz.NET — scheduled and recurring jobs with persistence and dashboards.
- MassTransit, NServiceBus, Wolverine — messaging frameworks on top of RabbitMQ / Azure Service Bus, handling retries, outbox and sagas.
public class InvoiceWorker(Channel<int> orders, IServiceScopeFactory scopes) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var orderId in orders.Reader.ReadAllAsync(stoppingToken))
{
using var scope = scopes.CreateScope();
var invoices = scope.ServiceProvider.GetRequiredService<InvoiceService>();
await invoices.GenerateAsync(orderId, stoppingToken);
}
}
}Key takeaways
- Keep requests fast: do essential work synchronously, queue the rest, return
202 Acceptedwhen appropriate. - Queues decouple producers from consumers, level load and let you scale workers independently.
- Queues deliver commands to one consumer; pub/sub delivers events to all subscribers.
- Expect at-least-once delivery: idempotent consumers, retries with backoff, dead-letter queues, and the outbox for reliable publishing.
Quiz
4 questions · 3 correct to pass1.A user uploads a video that takes 5 minutes to transcode. What's the best design?
2.What's the difference between a queue and publish/subscribe?
3.Most brokers deliver messages "at least once". What must consumers therefore be?
4.What is a dead-letter queue for?
To complete this lesson
- ○ Pass the quiz (3/4 correct)