avocadoo
All postsCloud

Reliable Messaging with Azure Service Bus and .NET

How to build reliable message-driven .NET services on Azure Service Bus: queues vs topics, peek-lock settlement, retries and dead-lettering, duplicate detection, sessions for ordering, idempotent consumers, and the configuration choices that matter in production.

Tran Quang · Aug 27, 2026 · 7 min read

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:

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:

SettlementEffectWhen to use
CompleteMessage removedProcessing succeeded
AbandonLock released, delivery count incrementsTransient failure, retry soon
Dead-letterMoved to the dead-letter subqueuePermanent failure, needs inspection
DeferSet aside, retrievable by sequence numberMust be processed later in a specific order
Lock expirySame as abandonConsumer 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:

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

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.