Skip to main content
SEMastery

Messaging Made Easy with Azure Service Bus

A simple, friendly guide to Azure Service Bus messaging in .NET — queues, topics, dead-letter queues, sessions, and clean producer and consumer code.

13 min readUpdated March 29, 2026

A tiffin dabba for your messages

Think about how a dabbawala in Mumbai delivers lunch.

Your mother packs a hot tiffin in the morning. She does not need to know exactly where you are or what you are doing at that moment. She hands the dabba to the dabbawala. The dabbawala carries it, keeps it safe, and delivers it to your office. You pick it up when you are free to eat. Mother and you never had to meet. The lunch still reached you.

Azure Service Bus is the dabbawala for your software.

One part of your app (the sender) packs a small message and hands it over. Service Bus carries it and keeps it safe. Another part of your app (the receiver) picks it up when it is ready. The two parts never have to be awake at the same time. If the receiver is busy or even switched off for a while, the message simply waits in the queue. Nothing is lost.

This idea is called asynchronous messaging, and it is one of the calmest, most reliable ways to connect the different pieces of a system.

The sender hands a message to Service Bus, which holds it until the receiver is ready.

Why not just call the other service directly?

A fair question. Why bother with a post office? Why not let one service phone the other directly with an HTTP call?

Direct calls work, but they are fragile. If the second service is slow, your first service waits and slows down too. If the second service is restarting, your call fails and the work is lost. If a sudden rush of orders arrives, both services get crushed at once.

Messaging fixes all three problems at once.

Problem with direct callsHow Service Bus helps
Receiver is down right nowMessage waits safely in the queue until it comes back
Sudden spike of trafficQueue absorbs the rush; receiver drains it at its own pace
Receiver is slowSender does not wait; it sends and moves on
Work gets lost on a crashMessage stays until it is fully processed and completed

This gentle decoupling is the whole point. The sender and receiver only agree on the shape of the message. They do not need to know each other's address, speed, or schedule.

The two main shapes: queues and topics

Service Bus gives you two ways to move messages. Picking the right one is most of the job.

A queue is one-to-one. One message is read by exactly one receiver, and then it is gone. This is perfect for work that should be done once — like "charge this card" or "resize this photo." If you run five copies of your worker, Service Bus shares the messages among them, and each message still goes to only one worker.

A topic is one-to-many. One message is copied to every subscription attached to the topic. This is perfect for news that many services care about — like "order #482 was placed." The billing service, the email service, and the warehouse service can each have their own subscription and each get their own copy.

A queue delivers each message to one worker; a topic copies each message to every subscription.

A simple rule of thumb:

If you want...Use a
One worker to do a job onceQueue
Many services to react to one eventTopic with subscriptions
To balance load across workersQueue (or one subscription)
To add a new listener later without touching the senderTopic

The package you actually use

In .NET, you use the Azure.Messaging.ServiceBus NuGet package. This is the current, supported client.

Note: The older WindowsAzure.ServiceBus and Microsoft.Azure.ServiceBus packages are being retired on 30 September 2026. Always start new projects with Azure.Messaging.ServiceBus.

Install it like this:

dotnet add package Azure.Messaging.ServiceBus

The package gives you three friendly types you will use again and again:

  • ServiceBusClient — the main connection. Create one and share it for the whole app's life. It is expensive to create, so do not make a new one per message.
  • ServiceBusSender — used to send messages to a queue or topic.
  • ServiceBusProcessor — the easy way to receive messages. It pulls messages, hands them to your code, and handles retries and threading for you.

Sending your first message

Sending is short and sweet. You create one client, ask it for a sender, and send a message.

using Azure.Messaging.ServiceBus;
 
// Create ONE client for the whole app. Reuse it everywhere.
string connectionString = "<your-connection-string>";
await using var client = new ServiceBusClient(connectionString);
 
// A sender pointed at a specific queue.
ServiceBusSender sender = client.CreateSender("orders-queue");
 
// Build a message. The body is just bytes, so we send JSON text.
var body = """{ "orderId": 482, "amount": 1499 }""";
var message = new ServiceBusMessage(body)
{
    ContentType = "application/json",
    MessageId = "order-482"   // helps with duplicate detection
};
 
await sender.SendMessageAsync(message);
Console.WriteLine("Order message sent. The worker will pick it up later.");

That is the entire send path. Notice we never waited for a worker. We packed the dabba and handed it over.

In a real ASP.NET Core app you would register the client once with dependency injection so it is shared cleanly:

// Program.cs
builder.Services.AddSingleton(_ =>
    new ServiceBusClient(builder.Configuration.GetConnectionString("ServiceBus")));

The send path

Build client
Create sender
Make message
Send
Move on

Steps

1

Build client

One ServiceBusClient for the whole app

2

Create sender

Point it at a queue or topic

3

Make message

Body plus MessageId and content type

4

Send

SendMessageAsync hands it to Service Bus

5

Move on

Sender does not wait for a receiver

What happens, step by step, when you send a message.

Receiving messages the easy way

To receive, the ServiceBusProcessor is your best friend. You give it two small functions: one for each message, and one for errors. It runs in the background and calls them for you.

await using var client = new ServiceBusClient(connectionString);
 
var processor = client.CreateProcessor("orders-queue", new ServiceBusProcessorOptions
{
    MaxConcurrentCalls = 5,        // handle up to 5 messages at once
    AutoCompleteMessages = false   // we will complete manually, only on success
});
 
processor.ProcessMessageAsync += async args =>
{
    string body = args.Message.Body.ToString();
    Console.WriteLine($"Got order: {body}");
 
    // ... do the real work here (save to DB, charge card, etc.)
 
    // Tell Service Bus we are done. Now the message is removed for good.
    await args.CompleteMessageAsync(args.Message);
};
 
processor.ProcessErrorAsync += args =>
{
    Console.WriteLine($"Something failed: {args.Exception.Message}");
    return Task.CompletedTask;
};
 
await processor.StartProcessingAsync();
Console.WriteLine("Listening for orders. Press any key to stop.");
Console.ReadKey();
await processor.StopProcessingAsync();

The most important line is CompleteMessageAsync. It is your "I have finished, you can delete this now" signal. Until you call it, Service Bus only locks the message for you; it does not delete it.

Lock, complete, and the safety it gives you

This two-step receive — first lock, then complete — is what makes Service Bus reliable.

When the processor hands you a message, Service Bus puts a temporary lock on it. The message is hidden from other workers but not yet deleted. Then one of three things happens:

  • You finish and call CompleteMessageAsync. The message is deleted. Done.
  • Your code throws or crashes before completing. The lock eventually expires, and the message becomes visible again so it can be retried.
  • You decide you cannot handle it and call AbandonMessageAsync to put it back immediately, or DeadLetterMessageAsync to set it aside.
The life of a single message as it moves through lock, processing, and completion.

Because of this, a crash never silently eats your work. The message comes back. This is called at-least-once delivery: a message will arrive at least once, and sometimes more than once. We will handle the "more than once" part next.

Handling the same message twice (idempotency)

Since a message can be delivered more than once, your handler must be safe to run twice. This safety is called being idempotent.

Imagine your handler charges a credit card. If the same "charge order 482" message arrives twice, you do not want to charge the customer twice. The fix is to remember what you have already done.

processor.ProcessMessageAsync += async args =>
{
    string messageId = args.Message.MessageId;
 
    // Have we already handled this exact message? (e.g. a row in a table)
    if (await _processedStore.AlreadyHandledAsync(messageId))
    {
        await args.CompleteMessageAsync(args.Message); // just acknowledge it
        return;
    }
 
    await DoTheRealWorkAsync(args.Message);
    await _processedStore.MarkHandledAsync(messageId);
 
    await args.CompleteMessageAsync(args.Message);
};

This is exactly the Inbox Pattern. You keep a small table of message IDs you have already processed and skip any you have seen before. Service Bus also has built-in duplicate detection on the standard and premium tiers, which drops messages with a repeated MessageId inside a time window (10 minutes by default, up to 7 days). The two work well together.

The dead-letter queue: a safe corner for broken messages

Sometimes a message simply cannot be processed. Maybe it is malformed, or it points at an order that no longer exists. You do not want it to retry forever and block the line.

Every queue and every subscription has a built-in side mailbox called the dead-letter queue (DLQ). Service Bus moves a message there automatically when it has been delivered too many times (the MaxDeliveryCount, default 10) or when it expires. You can also send a message there yourself.

processor.ProcessMessageAsync += async args =>
{
    try
    {
        var order = ParseOrder(args.Message.Body);
        if (order is null)
        {
            // This will never succeed. Set it aside with a reason.
            await args.DeadLetterMessageAsync(
                args.Message,
                deadLetterReason: "InvalidJson",
                deadLetterErrorDescription: "Body was not a valid order.");
            return;
        }
 
        await ProcessAsync(order);
        await args.CompleteMessageAsync(args.Message);
    }
    catch (Exception)
    {
        // Don't complete. Let it retry; after MaxDeliveryCount it dead-letters itself.
        await args.AbandonMessageAsync(args.Message);
    }
};

The dead-letter queue is a normal queue with a special name (orders-queue/$DeadLetterQueue). You can connect a receiver to it, read the broken messages, see why they failed (the reason is stored on the message), fix your bug, and replay them.

Where a failing message ends up

Delivered
Failed
Retried
Max reached
Dead-letter

Steps

1

Delivered

Processor receives the message

2

Failed

Handler throws, message is abandoned

3

Retried

Lock expires, message becomes visible again

4

Max reached

Delivery count passes MaxDeliveryCount

5

Dead-letter

Moved to the DLQ with a reason, not lost

A message is retried a few times before being set aside safely.

Keeping order with sessions

A plain queue spreads messages across many workers, so two messages can be handled at the same time and finish out of order. Usually that is fine. But sometimes order matters — all the steps for one bank account, or all the events for one chat conversation, must run in order.

For that, Service Bus has sessions. You stamp related messages with the same SessionId. Service Bus then guarantees that all messages for one session go to one worker, in order, one at a time. Different sessions can still run in parallel, so you keep order without losing speed.

// Sending: tag related messages with the same session id.
var message = new ServiceBusMessage(body)
{
    SessionId = "account-9001"
};
await sender.SendMessageAsync(message);
 
// Receiving: use a session processor instead of the plain one.
var sessionProcessor = client.CreateSessionProcessor(
    "transfers-queue",
    new ServiceBusSessionProcessorOptions { MaxConcurrentSessions = 10 });

The queue must be created as session-enabled for this to work. A small tip: when you use sessions together with duplicate detection, set the message's partition key to match the session id so both features agree.

A picture of the whole flow

Here is the full story, from the moment an order is placed to the moment three services have each done their part.

End-to-end: an order event is published once and three services react independently.

The Order API published once. It did not know or care who was listening. Tomorrow you can add a "loyalty points" service with a new subscription, and the API never changes. That is the quiet power of messaging.

Small habits that keep you out of trouble

A few gentle rules will save you many late nights:

  • Reuse one ServiceBusClient. Creating it opens a network connection. Make one, share it, and let dependency injection hand it out.
  • Always set a MessageId. It is your key for duplicate detection and for your own inbox table.
  • Complete only on success. If you complete a message before the work is truly done, a crash loses the work. Complete last.
  • Make handlers idempotent. Assume every message can arrive twice. Design so that running twice is harmless.
  • Watch the dead-letter queue. Set up an alert on its message count. A growing DLQ is your system quietly telling you something is broken.
  • Keep messages small. Send IDs and key fields, not giant blobs. If you must move a big file, put it in Blob Storage and send a link.

References and further reading

Quick recap

  • Service Bus is a cloud post office. The sender drops a message; the receiver picks it up later. They never need to be online together.
  • Queues are one-to-one (work done once). Topics are one-to-many (news many services react to).
  • Use the Azure.Messaging.ServiceBus package, with ServiceBusClient, ServiceBusSender, and ServiceBusProcessor.
  • A message is locked, then completed. Complete only when the work truly succeeds, so a crash never loses work.
  • Delivery is at-least-once, so make handlers idempotent — pair Service Bus with the Inbox Pattern and duplicate detection.
  • The dead-letter queue safely holds messages that keep failing, with a reason, so you can fix and replay them.
  • Use sessions when related messages must be processed in order by one worker.

Related Patterns