Messaging and Queues
By the end of this lesson
Decouple producers from consumers and absorb load spikes.
A queue is a durable list. One process appends work to it, another takes work off it, and neither has to be running at the same moment. The producer's obligation ends when the broker has accepted the message; whether the consumer is busy, restarting or scaled to zero is not the producer's problem.
Two things come out of that. The first is decoupling in time: a web request can hand over a job that takes ninety seconds and answer the user immediately. The second is absorbing spikes. Invoicing runs on the first of the month and two thousand invoices need rendering; the renderer manages about twenty a second. Without a queue, two thousand requests arrive at once and something falls over. With one, the work becomes a hundred seconds of steady load and nothing is lost. The queue does not make the work faster. It makes the arrival rate somebody else's problem.
// Producer — the web request returns as soon as the broker has the message
public sealed class InvoiceRenderingQueue(ServiceBusSender sender)
{
public Task EnqueueAsync(int invoiceId, string correlationId, CancellationToken token)
{
var message = new ServiceBusMessage(
JsonSerializer.SerializeToUtf8Bytes(new RenderInvoice(invoiceId)))
{
MessageId = "render-invoice-" + invoiceId, // duplicate-detection key
CorrelationId = correlationId,
SessionId = invoiceId.ToString(), // ordering, per invoice only
};
return sender.SendMessageAsync(message, token);
}
}
// Consumer — a background service, four messages at a time
protected override async Task ExecuteAsync(CancellationToken stopping)
{
ServiceBusProcessor processor = client.CreateProcessor(
"invoice-rendering",
new ServiceBusProcessorOptions
{
MaxConcurrentCalls = 4,
AutoCompleteMessages = false, // completing means the work is finished
});
processor.ProcessMessageAsync += HandleAsync;
processor.ProcessErrorAsync += args =>
{
log.LogError(args.Exception, "Processor error on {Entity}.", args.EntityPath);
return Task.CompletedTask;
};
await processor.StartProcessingAsync(stopping);
try { await Task.Delay(Timeout.Infinite, stopping); }
finally { await processor.StopProcessingAsync(CancellationToken.None); }
}- The producer is finished once the broker acknowledges the message. Nothing in this method knows whether a consumer exists, which is the decoupling you are buying.
- MessageId gives brokers with duplicate detection something to compare. It suppresses a repeat within a configured window — a window, not a promise, so the consumer still has to tolerate duplicates.
- SessionId groups messages so everything for one invoice is handled in order, by one consumer, one at a time. Note what it does not give you: any ordering between different invoices. Per-key ordering is usually what the business actually needs, and it is much cheaper than global ordering.
- Turning off automatic completion is the important line. With it on, the message is marked done before your handler finishes, so a crash mid-render loses the work and the queue looks healthy. Completing it yourself means completion states that the work happened.
- Each message needs its own dependency injection scope, exactly as a web request does. Resolving a DbContext once for the worker's lifetime gives four concurrent messages one shared context, and the errors that produces are intermittent and hard to read.
- Stopping deliberately on shutdown lets messages in flight finish instead of being abandoned. Abandoned work is not lost — it is redelivered — but redelivery is where duplicates come from, so reducing it is worth two lines.
The same piece of work called directly, or queued:
| Direct call | Through a queue | |
|---|---|---|
| Caller waits | Until the work is done | Until the broker accepts the message |
| Consumer unavailable | The caller fails | The message waits |
| Two thousand arrivals at once | Two thousand concurrent attempts | A backlog that drains at the consumer's pace |
| Caller learns the result | In the return value | Not at all, unless something reports back |
| Failure is visible | As a failed request | Only if you monitor the queue and the dead letter queue |
| Duplicates | Only if the caller retries | Expected, because delivery is at-least-once |
| Ordering | Whatever the caller does in sequence | Per key at best, and retries still disturb it |
| Extra to operate | Nothing | A broker, queue depth alerts, a dead letter process |
The vocabulary that decides how your handler has to be written:
- At-most-once
- The message may be lost but never repeated. Acceptable for a metric sample, not for raising an invoice.
- At-least-once
- The message is never lost and may repeat. This is what practical brokers give you, and it is the assumption every handler must be written against.
- "Exactly-once"
- Real within a broker's own boundary, and not achievable end to end. Your handler writes to a database and sends an email; no broker can make those two atomic with its own acknowledgement. Treat the phrase as a feature name, not a guarantee, and make the handler idempotent anyway.
- Lock or visibility timeout
- A consumer takes a message and holds it invisibly for a period. Finish and complete it and it is gone; crash, or take too long, and it becomes visible again for someone else. Work that outlasts the lock is redelivered while the first attempt is still running.
- Competing consumers
- Several instances reading one queue. This is how you scale throughput, and it is also what removes ordering: four consumers finish in whatever order the work takes.
- Ordering guarantees
- Global order across a busy queue means one consumer, which caps throughput at one message at a time. Per-key order — a session or partition key — keeps sequence where it matters and lets unrelated keys run in parallel. A retry still moves a message behind later ones unless the key is locked.
- Dead letter queue
- A separate queue for messages that cannot be processed. It stops one unprocessable message from blocking a queue forever, and it only works if somebody is told when something lands there.
- Queue depth and oldest message age
- Depth tells you how much is waiting; age tells you whether it is moving. Ten thousand messages draining steadily is healthy. Three messages sitting for an hour is an incident, and depth alone will not show it.
private const int MaxAttempts = 5;
private async Task HandleAsync(ProcessMessageEventArgs args)
{
RenderInvoice work = args.Message.Body.ToObjectFromJson<RenderInvoice>();
await using AsyncServiceScope scope = scopes.CreateAsyncScope();
var renderer = scope.ServiceProvider.GetRequiredService<IInvoiceRenderer>();
try
{
// Contains the idempotency guard: rendering the same invoice twice
// must not produce two documents or two emails.
await renderer.RenderAsync(work.InvoiceId, args.CancellationToken);
await args.CompleteMessageAsync(args.Message, args.CancellationToken);
}
catch (InvoiceNotFoundException ex)
{
// No number of retries will make this invoice exist.
await args.DeadLetterMessageAsync(
args.Message, "UnknownInvoice", ex.Message, args.CancellationToken);
}
catch (Exception ex) when (args.Message.DeliveryCount >= MaxAttempts)
{
log.LogError(ex, "Giving up on invoice {InvoiceId} after {Attempts} attempts.",
work.InvoiceId, args.Message.DeliveryCount);
await args.DeadLetterMessageAsync(
args.Message, "AttemptLimitReached", ex.Message, args.CancellationToken);
}
// Anything else: leave the message uncompleted. The broker redelivers it
// when the lock expires, and DeliveryCount goes up by one.
}- Two kinds of failure need two responses. A message referring to an invoice that does not exist will fail identically five times, so it goes straight to the dead letter queue and stops occupying a consumer.
- A transient failure — a timeout, a deadlock, a restarting dependency — is worth another attempt, and the broker's DeliveryCount is the counter you use rather than one of your own.
- The absence of code at the end is the mechanism. Not completing a message is what causes redelivery. That is invisible in review, which is why the comment is there.
- Without an attempt limit, a message that always fails is redelivered forever. On a session-based queue it blocks every later message for the same invoice; on a plain queue it burns consumer capacity and fills your logs with the same stack trace.
- Dead lettering is half a solution. A dead letter queue with no alarm is where work goes to be forgotten, and the symptom is a customer asking about an invoice nobody can find. Alarm on a depth above zero, give it an owner, and make sure a fixed message can be replayed.
- The render itself still needs the idempotency guard from the event-driven lesson. Redelivery is normal operation, not an edge case, so producing a second document has to be impossible rather than unlikely.
Before you move work onto a queue, have an answer for each of these:
- Who is told when something reaches the dead letter queue, and how a message is replayed once the defect is fixed.
- What the alert is on oldest message age, not only on depth. A backlog that is draining and a backlog that is stuck look identical on a count.
- What the consumer's concurrency limit is. Fifty consumers can flatten the database the queue was protecting, so the limit is part of the design rather than a default you inherited.
- How large the payload is. Identifiers and a little context belong in the message; a rendered document does not. Store the large thing and send a reference to it.
- What the acceptable lag is, agreed with whoever owns the process. "Within five minutes" is a number you can alert on. "Soon" is not.
- Whether the handler is idempotent, proved by a test that processes the same message twice and asserts the state is identical.
- What the user sees while the work is waiting. Silence is the default and it produces support calls.
Summary
- A queue decouples producer from consumer in time and turns a spike into a backlog
- Delivery is at-least-once, so duplicates are normal and handlers must be idempotent
- Complete a message only when the work is finished, and dead letter what can never succeed
- Ordering is per key at best, and adding consumers for throughput removes it
- Alert on dead letters and on oldest message age, or failures become silent
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Crash a consumer on purpose
Take a queue consumer that does real work. Make it throw immediately after the side effect but before completing the message, then run it.
Record what happens to the message, how many times the side effect occurs, and what your monitoring showed.
Show solution
The message comes back when the lock expires and the side effect happens again. That is at-least-once delivery working exactly as specified, reproduced in about a minute.
If the work now exists twice — two documents, two emails, two ledger rows — the handler is not idempotent, and nothing about the broker's configuration will save it. The guard has to be in your code, backed by a unique constraint or the remote system's own idempotency key.
Now reverse the experiment: turn automatic completion on and crash before the work finishes. The message is gone and the work never happened, which is the worse failure because nothing anywhere records it. This is the argument for completing the message yourself.
The monitoring part matters as much as the code. If neither failure produced an alert, your queue is a place where work quietly does not happen, and the first report will come from a customer.
Think about it
Which of these belongs on a queue?
Decide for each, and say what the user sees: emailing an invoice to a customer; taking the payment during checkout; generating a 200-page month-end report; writing an audit row for an approval.
Show solution
Emailing the invoice: queue it. Nobody waits for an email, the provider can be slow or down, and a retry is safe once the handler is idempotent. The user sees the invoice as sent, which is a small honesty question worth deciding — "queued for sending" is more accurate.
Taking the payment during checkout: do not queue it. The customer is waiting for a yes or no, and the decision changes what happens next. A queue here turns one clear answer into a pending state, a polling loop and a customer refreshing the page.
The month-end report: queue it, and give it a queue of its own. It is slow, it is bursty, and on a shared queue it blocks everything behind it for hours. The user needs a progress or ready-for-download state; silence for twenty minutes is indistinguishable from a broken button.
The audit row: do not queue it. It is a small write in the same database as the change it records, so it belongs in the same transaction. Queuing it creates a window where the change exists and the audit trail does not, which is precisely the thing an audit trail is meant to prevent.
The pattern: queue work that is slow, bursty, or tolerant of delay, and keep work that the caller's next decision depends on, or that must be atomic with a state change.
Saved in this browser only.