Reliable Messaging with Azure Service Bus and .NET
Messaging looks simple in a demo: one service sends, another receives. In production, the interesting questions are what happens when the receiver crashes halfway through, when the same message arrives twice, when a poison message blocks a queue, or when order matters for one customer but not across customers. Azure Service Bus has answers to all of these, but only if you use its features deliberately.
This post walks through building reliable producers and consumers with the Azure.Messaging.ServiceBus SDK, and the design decisions that separate a message queue that works from one you can trust.
Queues, topics, and subscriptions
Service Bus offers two entity types:
- Queues deliver each message to one competing consumer. Use them for commands and work distribution, such as "generate invoice for order 123".
- Topics with subscriptions deliver a copy of each message to every subscription, and each subscription behaves like its own queue. Use them for events, such as "order 123 was placed", where several services react independently.
Subscriptions can have SQL or correlation filters, so a subscriber only receives what it cares about. The tiers differ in features: Basic supports queues only, while topics, sessions, and duplicate detection need Standard or Premium. Premium adds dedicated capacity, network isolation, and larger message sizes. Check the current tier comparison in the docs before committing.
Provisioning with the Azure CLI
Most reliability settings live on the entity, not in code, so define them explicitly:
az servicebus namespace create \
--resource-group rg-orders-prod \
--name sb-orders-prod \
--location southeastasia \
--sku Standard
az servicebus queue create \
--resource-group rg-orders-prod \
--namespace-name sb-orders-prod \
--name invoice-requests \
--lock-duration PT1M \
--max-delivery-count 5 \
--enable-dead-lettering-on-message-expiration true \
--enable-duplicate-detection true \
--duplicate-detection-history-time-window PT10M
az servicebus topic create \
--resource-group rg-orders-prod \
--namespace-name sb-orders-prod \
--name order-events
az servicebus topic subscription create \
--resource-group rg-orders-prod \
--namespace-name sb-orders-prod \
--topic-name order-events \
--name shipping \
--max-delivery-count 5
Duplicate detection and sessions can only be set when the entity is created, so decide on them up front.
Connecting with managed identity
Avoid connection strings. Construct ServiceBusClient with the fully qualified namespace and a TokenCredential, and grant the app the Azure Service Bus Data Sender or Data Receiver role. With Microsoft.Extensions.Azure, registration looks like this:
using Azure.Identity;
using Microsoft.Extensions.Azure;
builder.Services.AddAzureClients(clients =>
{
clients.AddServiceBusClientWithNamespace("sb-orders-prod.servicebus.windows.net");
clients.UseCredential(new DefaultAzureCredential());
});
ServiceBusClient, senders, and processors are designed to be long-lived and thread-safe. Register them as singletons and reuse them; creating a client per message opens a new connection each time and will hurt throughput and stability.
Sending messages correctly
A message is more than a body. Set metadata that consumers and operators will need later:
public sealed class InvoicePublisher(ServiceBusClient client) : IAsyncDisposable
{
private readonly ServiceBusSender _sender = client.CreateSender("invoice-requests");
public async Task RequestInvoiceAsync(Guid orderId, CancellationToken ct)
{
var message = new ServiceBusMessage(BinaryData.FromObjectAsJson(new { OrderId = orderId }))
{
MessageId = $"invoice-{orderId}", // stable ID enables duplicate detection
ContentType = "application/json",
Subject = "InvoiceRequested",
CorrelationId = Activity.Current?.TraceId.ToString()
};
message.ApplicationProperties["schemaVersion"] = 1;
await _sender.SendMessageAsync(message, ct);
}
public ValueTask DisposeAsync() => _sender.DisposeAsync();
}
The MessageId matters. With duplicate detection enabled, Service Bus drops a message whose ID it has already seen within the configured window. A deterministic ID derived from business data makes producer retries safe. A random Guid.NewGuid() makes duplicate detection useless.
Batching
For high-volume sends, use batches so you pay for one network call per batch instead of per message:
using ServiceBusMessageBatch batch = await _sender.CreateMessageBatchAsync(ct);
foreach (var evt in events)
{
if (!batch.TryAddMessage(new ServiceBusMessage(BinaryData.FromObjectAsJson(evt))))
{
throw new InvalidOperationException("Message too large for an empty batch.");
}
}
await _sender.SendMessagesAsync(batch, ct);
In real code, when TryAddMessage returns false on a non-empty batch, send the current batch and start a new one.
Receiving with ServiceBusProcessor
ServiceBusProcessor handles the receive loop, concurrency, and lock renewal. Host it in a BackgroundService:
public sealed class InvoiceWorker(ServiceBusClient client, ILogger<InvoiceWorker> logger, InvoiceService invoices)
: BackgroundService
{
private ServiceBusProcessor? _processor;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_processor = client.CreateProcessor("invoice-requests", new ServiceBusProcessorOptions
{
AutoCompleteMessages = false,
MaxConcurrentCalls = 8,
PrefetchCount = 16,
MaxAutoLockRenewalDuration = TimeSpan.FromMinutes(5)
});
_processor.ProcessMessageAsync += HandleAsync;
_processor.ProcessErrorAsync += args =>
{
logger.LogError(args.Exception, "Service Bus error from {Source} on {Entity}",
args.ErrorSource, args.EntityPath);
return Task.CompletedTask;
};
await _processor.StartProcessingAsync(stoppingToken);
await Task.Delay(Timeout.Infinite, stoppingToken).ContinueWith(_ => { });
}
private async Task HandleAsync(ProcessMessageEventArgs args)
{
var request = args.Message.Body.ToObjectFromJson<InvoiceRequest>();
try
{
await invoices.GenerateAsync(request.OrderId, args.CancellationToken);
await args.CompleteMessageAsync(args.Message);
}
catch (ValidationException ex)
{
await args.DeadLetterMessageAsync(args.Message, "ValidationFailed", ex.Message);
}
}
public override async Task StopAsync(CancellationToken cancellationToken)
{
if (_processor is not null)
{
await _processor.StopProcessingAsync(cancellationToken);
await _processor.DisposeAsync();
}
await base.StopAsync(cancellationToken);
}
}
Setting AutoCompleteMessages = false and settling explicitly makes the intent obvious in code. Transient exceptions are simply allowed to bubble up: the processor abandons the message, it becomes visible again, and its delivery count increases.
Peek-lock, retries, and dead-lettering
In the default peek-lock mode, a received message is locked, not removed. The consumer then settles it:
| Settlement | Effect | When to use |
|---|---|---|
| Complete | Message removed | Processing succeeded |
| Abandon | Lock released, delivery count increments | Transient failure, retry soon |
| Dead-letter | Moved to the dead-letter subqueue | Permanent failure, needs inspection |
| Defer | Set aside, retrievable by sequence number | Must be processed later in a specific order |
| Lock expiry | Same as abandon | Consumer crashed or took too long |
Once the delivery count exceeds MaxDeliveryCount, Service Bus dead-letters the message automatically. That is your poison-message protection. ReceiveAndDelete mode skips locking entirely and is faster, but a crash loses the message; reserve it for data you can afford to drop.
Immediate abandon-and-retry is not a backoff strategy. For failures that need time, such as a downstream outage, schedule a copy of the message for later and complete the original, or rely on the SDK's ServiceBusRetryOptions for transport-level retries only.
Handling the dead-letter queue
A dead-letter queue nobody reads is just a slower way of losing data. Monitor its message count and build a small tool to inspect and resubmit:
ServiceBusReceiver dlq = client.CreateReceiver("invoice-requests",
new ServiceBusReceiverOptions { SubQueue = SubQueue.DeadLetter });
foreach (var msg in await dlq.ReceiveMessagesAsync(maxMessages: 50, TimeSpan.FromSeconds(5)))
{
logger.LogWarning("DLQ {Id}: {Reason} - {Description}",
msg.MessageId, msg.DeadLetterReason, msg.DeadLetterErrorDescription);
await sender.SendMessageAsync(new ServiceBusMessage(msg)); // copy and resubmit
await dlq.CompleteMessageAsync(msg);
}
Note that resubmitting with the same MessageId inside the duplicate detection window will be silently dropped.
Idempotent consumers
Service Bus gives you at-least-once delivery. A consumer can finish its work and then crash before completing the message, or the lock can expire during a long operation. Either way, the message is delivered again. Your handler must tolerate that.
Practical options:
- Use natural idempotency, for example an upsert keyed by order ID rather than an insert.
- Record processed message IDs in the same database transaction as the business change, and skip messages already recorded.
- Pair the producer side with the transactional outbox pattern, so the database change and the message publish cannot diverge.
Duplicate detection on the broker helps with producer retries but does not protect you from redelivery to consumers. Idempotency is still required.
Ordering with sessions
Queues are not strictly ordered once you have concurrent consumers. When order matters within a group, such as all events for one customer, enable sessions on the entity and set SessionId on each message. Service Bus then guarantees FIFO processing within a session while different sessions are processed in parallel:
var processor = client.CreateSessionProcessor("customer-updates", new ServiceBusSessionProcessorOptions
{
MaxConcurrentSessions = 16,
MaxConcurrentCallsPerSession = 1,
AutoCompleteMessages = false
});
Choose the session key carefully. Too coarse, like a single tenant producing most traffic, and you serialize your throughput. Too fine and you gain nothing over a regular queue.
Production checklist
- Set
lock-durationlonger than your typical processing time, and useMaxAutoLockRenewalDurationfor outliers. - Keep
MaxDeliveryCountlow enough that poison messages fail fast. - Alert on dead-letter counts and on active message count growth, not just on errors.
- Propagate trace context; the SDK emits distributed tracing that OpenTelemetry can collect.
- Disable local (SAS key) authentication on the namespace once all clients use Entra ID.
- Load test with realistic
MaxConcurrentCallsandPrefetchCount; too much prefetch can cause lock expiry on buffered messages.
Conclusion
Service Bus provides the building blocks for reliable messaging: peek-lock settlement, delivery counts, dead-lettering, duplicate detection, and sessions. Reliability, however, comes from how you combine them. Use stable message IDs, settle messages explicitly, treat the dead-letter queue as an operational surface, and design every consumer to be idempotent. Get those right and the broker's guarantees become guarantees your system can actually rely on.