Reliable Messaging with Azure Service Bus and .NET

TQ
Tran Quang
August 27, 2026 · 8 min read
#Azure#Service Bus#Messaging#.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:

bash
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:

csharp
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:

csharp
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:

csharp
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:

csharp
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:

csharp
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:

csharp
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-duration longer than your typical processing time, and use MaxAutoLockRenewalDuration for outliers.
  • Keep MaxDeliveryCount low 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 MaxConcurrentCalls and PrefetchCount; 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.

Found this useful?

Share it with someone who might need it.

Related articles All articles