Some work should not happen while a web request is waiting: generating a large report, processing an uploaded file, or sending a batch of invoices. Moving that work to a queue lets an ASP.NET Core app respond promptly and gives a separate worker room to process tasks at its own pace. Azure Service Bus is a good fit when you need more than a basic fire-and-forget message.
Separate the request from the work
A typical setup has three parts: an ASP.NET Core application that accepts a request, an Azure Service Bus queue that holds a message, and a .NET worker that receives and handles it. The web app validates the request and records enough information to identify the work, then enqueues a small message. The worker can scale independently of the web app.
This moves background work out of request path without requiring the web application to stay alive until a long operation finishes. It also means a temporary worker outage does not necessarily lose accepted work: messages remain in the queue until they can be processed or expire.
Send a small, identifiable message
Messages should describe the work rather than carry a large file or a full database record. For example, a message can contain an invoice ID; the worker loads the current invoice details when it processes the task. This keeps messages small and avoids sending sensitive data through the queue unnecessarily.
Give each logical operation a stable identifier. Here, the invoice ID becomes the message ID, which can help with duplicate detection when that feature is enabled on the queue. The receiver should still be designed to handle a repeated delivery safely.
using Azure.Messaging.ServiceBus;
public sealed class InvoiceQueue
{
private readonly ServiceBusSender _sender;
public InvoiceQueue(ServiceBusSender sender)
{
_sender = sender;
}
public async Task QueueInvoiceAsync(Invoice invoice, CancellationToken cancellationToken)
{
// Keep the message small; the worker can load full invoice details by ID.
var message = new ServiceBusMessage(
BinaryData.FromObjectAsJson(new { invoice.Id }))
{
// Use a stable business identifier to support duplicate detection.
MessageId = invoice.Id.ToString("N"),
Subject = "InvoiceReady"
};
// Add simple metadata that helps consumers interpret the message.
message.ApplicationProperties["schemaVersion"] = 1;
// Pass cancellation through so a stopped request can cancel the send.
await _sender.SendMessageAsync(message, cancellationToken);
}
}
Invoice is an application model, and the sender should be created from a long-lived ServiceBusClient rather than constructing a new client for every request. In a real app, register the client and sender with dependency injection and keep the connection string in configuration backed by a secret store or managed identity.
Assume a message can arrive more than once
Service Bus delivery is generally at least once, not a promise that your handler runs exactly one time. A worker might finish its database update and then lose its connection before completing the message. When the lock expires, Service Bus can deliver that message again.
That is why duplicate message processing needs an application-level safeguard. For an invoice workflow, the database update might use a unique operation ID or a status transition that prevents the same invoice from being issued twice. Make the business action idempotent: repeating the same request should not repeat an irreversible side effect.
When a worker uses peek-lock mode, it receives a temporary lock while it processes the message. It should complete the message only after the work has succeeded. If a transient dependency is unavailable, allow a retry or abandon the message according to the chosen retry policy. Avoid completing first and doing the work afterward; a crash in between would lose the task.
Plan for failures that retries cannot fix
Retries are useful for temporary problems, such as a brief database timeout. They are not a cure for a malformed payload or a missing required record. Repeatedly retrying a permanent failure wastes capacity and delays other work.
After the configured delivery limit, Service Bus can move a message to the dead-letter queue. Inspect dead-lettered messages with their reason and description, correct the underlying issue, and then decide whether to resubmit them. A simple operational process for reviewing and replaying these messages is part of the workflow, not an optional afterthought.
Watch processing time and queue growth
Keep an eye on active message count, oldest-message age, dead-letter count, processing duration, and handler failures. A queue that keeps growing can indicate that the worker has too little capacity, that messages take longer than expected, or that a downstream service is throttling requests.
Also compare typical processing time with the message lock duration. If a task regularly outlasts its lock, another worker may receive the same message while the first is still working. For long-running jobs, consider renewing the lock while processing or splitting the task into smaller units. Log the message ID and a correlation ID so a request can be traced from the API through the worker and into its dependencies.
The practical benefit of this pattern is not simply that the work runs later. It gives the application a durable handoff, independent scaling, and a clear place to observe and recover failures—provided the worker treats retries and duplicate deliveries as normal conditions.