Why These Patterns Matter
Azure Service Bus provides three powerful messaging patterns that solve distinct but complementary problems in distributed systems. Understanding when and why to use each pattern is critical for building resilient, scalable architectures.
Auto-Forwarding
Auto-forwarding moves messages from one entity (queue or subscription) to another automatically on the server side — no consumer application required. Use it when you need:
- Fan-out routing — A single input queue distributes messages to multiple processing queues based on topic subscriptions.
- Multi-stage pipelines — Messages flow through a chain of queues, each representing a processing stage.
- Cross-namespace bridging — Route messages between namespaces for geographic distribution or team isolation.
- Decoupling producers from consumers — Producers send to one well-known queue; the topology behind it can change without affecting senders.
Message Deferral
Deferral lets a consumer acknowledge that a message exists but postpone processing it. The message remains in the queue but becomes invisible to normal receive operations. Use it when:
- Out-of-order arrival — You receive step 3 before step 2 and need to wait.
- Dependency waiting — A message requires data that hasn't arrived yet (e.g., waiting for a payment confirmation before shipping).
- Saga orchestration — Coordinating multi-step workflows where steps complete in unpredictable order.
Peek-Lock
Peek-lock lets you inspect and process messages without immediately removing them from the queue. If processing fails, the message becomes available again. Use it when:
- At-least-once delivery — You cannot afford to lose messages if a consumer crashes mid-processing.
- Auditing and inspection — You want to look at messages without consuming them.
- Long-running processing — Work takes longer than the default lock duration and you need renewal.
Architecture Diagrams
Auto-Forwarding Chain
┌─────────────────────────────────────────────────────────────────────┐
│ Auto-Forwarding Pipeline │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Producer │
│ │ │
│ ▼ │
│ ┌──────────────┐ auto-forward ┌──────────────────┐ auto-forward │
│ │ Input Queue │ ─────────────► │ Processing Queue │ ───────────► │
│ └──────────────┘ └──────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ Archive Queue│ │
│ └──────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
Topic Subscription Auto-Forwarding
┌─────────────────────────────────────────────────────────────────────┐
│ Topic Subscription Forwarding │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Producer ──► ┌───────────────┐ │
│ │ Orders Topic │ │
│ └───────┬───────┘ │
│ │ │
│ ┌────────────┼────────────┐ │
│ ▼ ▼ ▼ │
│ ┌──────────────┐ ┌─────────┐ ┌──────────────┐ │
│ │HighValue Sub │ │ All Sub │ │ Returns Sub │ │
│ │(filter: │ │(no │ │(filter: │ │
│ │ amount>1000) │ │ filter) │ │ type=return) │ │
│ └──────┬───────┘ └────┬────┘ └──────┬───────┘ │
│ │ auto-fwd │ auto-fwd │ auto-fwd │
│ ▼ ▼ ▼ │
│ ┌──────────────┐ ┌─────────┐ ┌──────────────┐ │
│ │ VIP Queue │ │ Archive │ │ Returns Queue│ │
│ └──────────────┘ └─────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
Peek-Lock Flow
┌─────────────────────────────────────────────────────────────────────┐
│ Peek-Lock Message Flow │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────┐ Receive(PeekLock) ┌──────────┐ │
│ │ Queue │ ──────────────────────► │ Consumer │ │
│ └─────────┘ (message locked) └────┬─────┘ │
│ ▲ │ │
│ │ Process Message │
│ │ │ │
│ │ ┌────────────────────┼────────────────┐ │
│ │ ▼ ▼ ▼ │
│ │ ┌────────────┐ ┌──────────────┐ ┌───────────┐ │
│ │ │ Complete │ │ Abandon │ │ Dead-Letter│ │
│ │ │ (remove │ │ (unlock, │ │ (poison │ │
│ │ │ from queue│ │ retry later)│ │ message) │ │
│ │ └────────────┘ └──────┬───────┘ └───────────┘ │
│ │ │ │
│ └─────────────────────────────────┘ │
│ (message available again) │
└─────────────────────────────────────────────────────────────────────┘
Auto-Forwarding Deep Dive
How It Works
Auto-forwarding is a server-side feature. When you configure a queue or subscription with a ForwardTo property, Service Bus automatically transfers every message that arrives in the source entity to the destination entity. Key characteristics:
- No consumer needed — The broker handles the transfer internally.
- Atomic with receive — The forward happens as part of the receive transaction; messages won't be lost.
- Same namespace only — Source and destination must be in the same Service Bus namespace (Premium tier supports cross-namespace via chaining with Azure Functions, but native forwarding is same-namespace).
- Destination must exist — If the destination entity doesn't exist at configuration time, creation fails.
Creating Queues with Auto-Forward via CLI
# Create the destination queue first
az servicebus queue create \
--resource-group myResourceGroup \
--namespace-name myNamespace \
--name processing-queue
# Create the source queue with auto-forwarding enabled
az servicebus queue create \
--resource-group myResourceGroup \
--namespace-name myNamespace \
--name input-queue \
--forward-to processing-queue
# Create an archive queue and update processing-queue to forward there
az servicebus queue create \
--resource-group myResourceGroup \
--namespace-name myNamespace \
--name archive-queue
az servicebus queue update \
--resource-group myResourceGroup \
--namespace-name myNamespace \
--name processing-queue \
--forward-to archive-queue
Creating Queues with Auto-Forward via C#
using Azure.Messaging.ServiceBus.Administration;
var adminClient = new ServiceBusAdministrationClient(connectionString);
// Create destination queue
await adminClient.CreateQueueAsync("processing-queue");
// Create source queue with auto-forwarding
await adminClient.CreateQueueAsync(new CreateQueueOptions("input-queue")
{
ForwardTo = "processing-queue"
});
Topic Subscription Auto-Forwarding
// Create a topic
await adminClient.CreateTopicAsync("orders");
// Create a subscription that auto-forwards high-value orders to a VIP queue
await adminClient.CreateQueueAsync("vip-queue");
await adminClient.CreateSubscriptionAsync(
new CreateSubscriptionOptions("orders", "high-value")
{
ForwardTo = "vip-queue"
});
// Add a filter so only high-value orders are forwarded
await adminClient.CreateRuleAsync("orders", "high-value",
new CreateRuleOptions("HighValueFilter")
{
Filter = new SqlRuleFilter("Amount > 1000")
});
// Remove the default "match all" rule
await adminClient.DeleteRuleAsync("orders", "high-value", "$Default");
Chaining Queues for Multi-Stage Processing
Auto-forwarding supports chaining up to a configurable depth. Each stage can have its own dead-letter queue and monitoring:
// Stage 1: Validation
await adminClient.CreateQueueAsync(new CreateQueueOptions("stage-validation")
{
ForwardTo = "stage-enrichment"
});
// Stage 2: Enrichment
await adminClient.CreateQueueAsync(new CreateQueueOptions("stage-enrichment")
{
ForwardTo = "stage-dispatch"
});
// Stage 3: Dispatch (terminal — no forwarding)
await adminClient.CreateQueueAsync("stage-dispatch");
Note: The maximum forwarding chain depth is 4 hops. Exceeding this results in the message being dead-lettered at the point where the limit is reached.
Cross-Entity Forwarding Rules and Limitations
| Rule | Detail |
|---|---|
| Same namespace | Source and destination must be in the same namespace |
| Entity must exist | Destination entity must exist when configuring forwarding |
| No circular chains | A → B → A is not allowed; Service Bus rejects circular configurations |
| Max chain depth | 4 hops maximum |
| Sessions | Forwarded messages lose session affinity unless destination is also session-enabled |
| Partitioning | Both source and destination must have the same partitioning configuration |
| Dead-letter forwarding | You can also set ForwardDeadLetteredMessagesTo to route DLQ messages |
Error Handling When Destination Is Full
When the destination entity reaches its maximum size:
- New messages to the source are rejected — Senders receive a
QuotaExceededException. - Messages already in the source stay there — They won't be forwarded until space is available.
- No automatic retry — You must monitor and resolve the capacity issue.
// Monitor destination queue size to prevent forwarding failures
QueueRuntimeProperties props = await adminClient.GetQueueRuntimePropertiesAsync("processing-queue");
long sizeInBytes = props.SizeInBytes;
long maxSizeInMB = (await adminClient.GetQueueAsync("processing-queue")).Value.MaxSizeInMegabytes;
double usagePercent = (double)sizeInBytes / (maxSizeInMB * 1024 * 1024) * 100;
if (usagePercent > 80)
{
// Alert: destination queue approaching capacity
logger.LogWarning("Queue at {Usage}% capacity", usagePercent);
}
Message Deferral Deep Dive
What Deferral Means
When you defer a message, it remains physically in the queue but is no longer visible to normal ReceiveMessageAsync calls. The only way to retrieve a deferred message is by its sequence number. Think of it as putting a bookmark in the queue — the message is parked until you explicitly ask for it.
Key characteristics:
- Not deleted — The message stays in the queue and counts toward the queue's size quota.
- Invisible to normal receive — Only retrievable via
ReceiveDeferredMessageAsyncwith the exact sequence number. - No automatic expiry from deferral — The message's TTL still applies. If TTL expires while deferred, it moves to the dead-letter queue.
- Sequence number is stable — It never changes, making it safe to store externally.
When to Use Deferral
Out-of-order messages:
Expected: Step1 → Step2 → Step3
Received: Step1 → Step3 → Step2
Action: Defer Step3, process Step2 when it arrives, then retrieve deferred Step3.
Waiting for dependencies:
Message: "Ship Order #123"
Dependency: Payment confirmation for Order #123 hasn't arrived yet.
Action: Defer the shipping message. When payment arrives, retrieve and process it.
Saga orchestration:
Saga: BookFlight → BookHotel → BookCar
BookCar arrives first. Defer it until BookFlight and BookHotel complete.
C# Code to Defer a Message
using Azure.Messaging.ServiceBus;
await using var client = new ServiceBusClient(connectionString);
var receiver = client.CreateReceiver("order-queue");
ServiceBusReceivedMessage message = await receiver.ReceiveMessageAsync();
if (message != null)
{
var orderStep = message.ApplicationProperties["Step"].ToString();
var expectedStep = await GetExpectedStepAsync(message.ApplicationProperties["OrderId"].ToString());
if (orderStep != expectedStep)
{
// Message arrived out of order — defer it
await receiver.DeferMessageAsync(message);
// Store the sequence number for later retrieval
await StoreSequenceNumberAsync(
orderId: message.ApplicationProperties["OrderId"].ToString(),
step: orderStep,
sequenceNumber: message.SequenceNumber
);
}
else
{
// Process normally
await ProcessOrderStepAsync(message);
await receiver.CompleteMessageAsync(message);
}
}
C# Code to Receive Deferred Messages by Sequence Number
// Retrieve a single deferred message
long sequenceNumber = await GetStoredSequenceNumberAsync(orderId, step);
ServiceBusReceivedMessage deferredMessage = await receiver.ReceiveDeferredMessageAsync(sequenceNumber);
await ProcessOrderStepAsync(deferredMessage);
await receiver.CompleteMessageAsync(deferredMessage);
// Retrieve multiple deferred messages at once
long[] sequenceNumbers = await GetAllDeferredSequenceNumbersAsync(orderId);
IReadOnlyList<ServiceBusReceivedMessage> deferredMessages =
await receiver.ReceiveDeferredMessagesAsync(sequenceNumbers);
foreach (var msg in deferredMessages)
{
await ProcessOrderStepAsync(msg);
await receiver.CompleteMessageAsync(msg);
}
Tracking Deferred Sequence Numbers
Deferred messages can only be retrieved by sequence number, so you must store these externally. Common approaches:
Azure Table Storage:
public class DeferredMessageTracker
{
private readonly TableClient _table;
public DeferredMessageTracker(string connectionString)
{
_table = new TableClient(connectionString, "DeferredMessages");
_table.CreateIfNotExists();
}
public async Task TrackAsync(string correlationId, string step, long sequenceNumber)
{
await _table.UpsertEntityAsync(new TableEntity(correlationId, step)
{
{ "SequenceNumber", sequenceNumber },
{ "DeferredAt", DateTimeOffset.UtcNow }
});
}
public async Task<long> GetSequenceNumberAsync(string correlationId, string step)
{
var entity = await _table.GetEntityAsync<TableEntity>(correlationId, step);
return (long)entity.Value["SequenceNumber"];
}
public async Task RemoveAsync(string correlationId, string step)
{
await _table.DeleteEntityAsync(correlationId, step);
}
}
Timeout Handling for Deferred Messages
Deferred messages can become orphaned if the triggering event never arrives. Implement a cleanup process:
public async Task CleanupStaleDeferralsAsync(TimeSpan maxAge)
{
string filter = $"DeferredAt lt datetime'{DateTimeOffset.UtcNow.Subtract(maxAge):O}'";
var staleEntries = _table.QueryAsync<TableEntity>(filter);
await foreach (var entry in staleEntries)
{
long seqNum = (long)entry["SequenceNumber"];
try
{
// Retrieve and dead-letter the stale deferred message
var message = await _receiver.ReceiveDeferredMessageAsync(seqNum);
await _receiver.DeadLetterMessageAsync(message, "DeferralTimeout",
$"Message deferred for longer than {maxAge.TotalHours} hours");
}
catch (ServiceBusException ex) when (ex.Reason == ServiceBusFailureReason.MessageNotFound)
{
// Message already expired via TTL — just clean up tracking
}
await _table.DeleteEntityAsync(entry.PartitionKey, entry.RowKey);
}
}
Tip: Run this cleanup on a timer (e.g., Azure Function with Timer trigger every 15 minutes) to prevent unbounded growth of deferred messages.
Peek-Lock Pattern Deep Dive
ReceiveAndDelete vs PeekLock
Service Bus offers two receive modes:
| Mode | Behavior | Risk | Use Case |
|---|---|---|---|
| ReceiveAndDelete | Message removed immediately on receive | Message lost if consumer crashes | High-throughput, idempotent workloads |
| PeekLock | Message locked but not removed; must be explicitly completed | Slower, but no data loss | Business-critical processing |
// ReceiveAndDelete — fire and forget
var receiver = client.CreateReceiver("my-queue",
new ServiceBusReceiverOptions { ReceiveMode = ServiceBusReceiveMode.ReceiveAndDelete });
// PeekLock — safe processing (default)
var receiver = client.CreateReceiver("my-queue",
new ServiceBusReceiverOptions { ReceiveMode = ServiceBusReceiveMode.PeekLock });
Peek Without Locking (Inspection)
Peeking lets you inspect messages without locking or consuming them. The message remains fully available to other consumers:
var receiver = client.CreateReceiver("my-queue");
// Peek at the next message without locking
ServiceBusReceivedMessage peeked = await receiver.PeekMessageAsync();
Console.WriteLine($"Peeked: {peeked.Body}");
// Message is still in the queue, untouched
// Peek multiple messages
IReadOnlyList<ServiceBusReceivedMessage> batch = await receiver.PeekMessagesAsync(maxMessages: 10);
// Peek from a specific sequence number
ServiceBusReceivedMessage specific = await receiver.PeekMessageAsync(fromSequenceNumber: 42);
Important: Peeked messages cannot be completed, abandoned, or dead-lettered. Peek is read-only.
PeekLock Flow: Receive → Process → Complete/Abandon/Dead-Letter
var receiver = client.CreateReceiver("order-queue");
ServiceBusReceivedMessage message = await receiver.ReceiveMessageAsync(TimeSpan.FromSeconds(30));
if (message != null)
{
try
{
await ProcessOrderAsync(message);
// Success — remove from queue
await receiver.CompleteMessageAsync(message);
}
catch (TransientException)
{
// Transient failure — put back for retry
await receiver.AbandonMessageAsync(message, new Dictionary<string, object>
{
{ "RetryReason", "TransientFailure" }
});
}
catch (PoisonMessageException ex)
{
// Permanent failure — move to dead-letter queue
await receiver.DeadLetterMessageAsync(message,
deadLetterReason: "PoisonMessage",
deadLetterErrorDescription: ex.Message);
}
}
Lock Renewal for Long-Running Processing
The default lock duration is 30 seconds (configurable up to 5 minutes at the queue level). For processing that takes longer, renew the lock:
var receiver = client.CreateReceiver("long-processing-queue");
ServiceBusReceivedMessage message = await receiver.ReceiveMessageAsync();
if (message != null)
{
using var cts = new CancellationTokenSource();
// Start background lock renewal
var renewalTask = Task.Run(async () =>
{
while (!cts.Token.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromSeconds(20), cts.Token);
await receiver.RenewMessageLockAsync(message);
}
}, cts.Token);
try
{
await LongRunningProcessAsync(message); // may take minutes
await receiver.CompleteMessageAsync(message);
}
finally
{
cts.Cancel();
}
}
Using the ServiceBusProcessor (recommended for production):
var processor = client.CreateProcessor("long-processing-queue", new ServiceBusProcessorOptions
{
AutoCompleteMessages = false,
MaxAutoLockRenewalDuration = TimeSpan.FromMinutes(10) // auto-renews lock
});
processor.ProcessMessageAsync += async args =>
{
await LongRunningProcessAsync(args.Message);
await args.CompleteMessageAsync(args.Message);
};
processor.ProcessErrorAsync += async args =>
{
logger.LogError(args.Exception, "Processing error");
};
await processor.StartProcessingAsync();
Handling Lock Expiration
If the lock expires before you complete the message, the message becomes visible to other consumers. Attempting to complete an expired lock throws ServiceBusException with reason MessageLockLost:
try
{
await receiver.CompleteMessageAsync(message);
}
catch (ServiceBusException ex) when (ex.Reason == ServiceBusFailureReason.MessageLockLost)
{
logger.LogWarning("Lock expired for message {Id}. Another consumer may process it.",
message.MessageId);
// Do NOT retry — another instance may already be processing this message.
// Ensure your processing is idempotent.
}
Real-World Scenarios
Order Processing Pipeline with Auto-Forwarding
┌──────────┐ auto-fwd ┌────────────┐ auto-fwd ┌─────────────┐
│ Incoming │ ──────────►│ Validation │ ──────────►│ Fulfillment │
│ Orders │ │ Queue │ │ Queue │
└──────────┘ └────────────┘ └─────────────┘
│ │
Consumer: Consumer:
validates address, picks, packs,
checks inventory ships order
Each stage has its own consumer that processes and completes messages. If validation fails, the consumer dead-letters the message instead of letting it forward. Only successfully validated orders reach fulfillment.
Saga Pattern Using Deferral
// Saga: Process order requires Payment + Inventory + Shipping confirmation
public async Task HandleSagaMessageAsync(ServiceBusReceivedMessage message,
ServiceBusReceiver receiver)
{
string orderId = message.ApplicationProperties["OrderId"].ToString();
string step = message.ApplicationProperties["SagaStep"].ToString();
var sagaState = await LoadSagaStateAsync(orderId);
if (!sagaState.CanProcess(step))
{
// Prerequisite steps not yet complete — defer
await receiver.DeferMessageAsync(message);
sagaState.TrackDeferred(step, message.SequenceNumber);
await SaveSagaStateAsync(sagaState);
return;
}
// Process this step
await ExecuteSagaStepAsync(step, message);
sagaState.MarkComplete(step);
// Check if any deferred messages can now be processed
var unblocked = sagaState.GetUnblockedDeferrals();
foreach (var seqNum in unblocked)
{
var deferred = await receiver.ReceiveDeferredMessageAsync(seqNum);
await ExecuteSagaStepAsync(
deferred.ApplicationProperties["SagaStep"].ToString(), deferred);
await receiver.CompleteMessageAsync(deferred);
sagaState.MarkComplete(deferred.ApplicationProperties["SagaStep"].ToString());
}
await receiver.CompleteMessageAsync(message);
await SaveSagaStateAsync(sagaState);
}
Message Inspection/Auditing with Peek
// Audit tool: inspect queue contents without consuming
public async Task<List<MessageSummary>> AuditQueueAsync(string queueName, int count = 50)
{
var receiver = _client.CreateReceiver(queueName);
var messages = await receiver.PeekMessagesAsync(maxMessages: count);
return messages.Select(m => new MessageSummary
{
MessageId = m.MessageId,
EnqueuedTime = m.EnqueuedTime,
Subject = m.Subject,
DeliveryCount = m.DeliveryCount,
Properties = m.ApplicationProperties.ToDictionary(k => k.Key, v => v.Value?.ToString())
}).ToList();
}
Testing
Testing Auto-Forwarding
[Fact]
public async Task AutoForwarding_MessageReachesDestination()
{
// Arrange: input-queue forwards to processing-queue
var sender = _client.CreateSender("input-queue");
var receiver = _client.CreateReceiver("processing-queue");
// Act
await sender.SendMessageAsync(new ServiceBusMessage("test payload"));
// Assert: message arrives in destination
var received = await receiver.ReceiveMessageAsync(TimeSpan.FromSeconds(10));
Assert.NotNull(received);
Assert.Equal("test payload", received.Body.ToString());
await receiver.CompleteMessageAsync(received);
}
Testing Deferral
[Fact]
public async Task Deferral_MessageRetrievableBySequenceNumber()
{
var sender = _client.CreateSender("test-queue");
var receiver = _client.CreateReceiver("test-queue");
await sender.SendMessageAsync(new ServiceBusMessage("deferred message"));
var message = await receiver.ReceiveMessageAsync();
await receiver.DeferMessageAsync(message);
long seqNum = message.SequenceNumber;
// Normal receive should not return the deferred message
var normalReceive = await receiver.ReceiveMessageAsync(TimeSpan.FromSeconds(2));
Assert.Null(normalReceive);
// Retrieve by sequence number
var deferred = await receiver.ReceiveDeferredMessageAsync(seqNum);
Assert.Equal("deferred message", deferred.Body.ToString());
await receiver.CompleteMessageAsync(deferred);
}
Testing Peek-Lock
[Fact]
public async Task PeekLock_AbandonedMessageBecomesAvailable()
{
var sender = _client.CreateSender("test-queue");
var receiver = _client.CreateReceiver("test-queue");
await sender.SendMessageAsync(new ServiceBusMessage("retry me"));
// First receive and abandon
var message = await receiver.ReceiveMessageAsync();
await receiver.AbandonMessageAsync(message);
// Message should be available again
var retried = await receiver.ReceiveMessageAsync(TimeSpan.FromSeconds(5));
Assert.NotNull(retried);
Assert.Equal("retry me", retried.Body.ToString());
Assert.Equal(2, retried.DeliveryCount);
await receiver.CompleteMessageAsync(retried);
}
Best Practices
| Pattern | Best Practice | Why |
|---|---|---|
| Auto-Forwarding | Monitor destination queue size | Prevents forwarding failures when destination is full |
| Auto-Forwarding | Keep chain depth ≤ 3 hops | Reduces complexity and debugging difficulty |
| Auto-Forwarding | Use dead-letter forwarding (ForwardDeadLetteredMessagesTo) | Centralizes poison message handling |
| Deferral | Always store sequence numbers externally | Deferred messages are unreachable without them |
| Deferral | Implement timeout cleanup | Prevents unbounded growth of deferred messages |
| Deferral | Set message TTL | Ensures deferred messages eventually expire |
| Peek-Lock | Use ServiceBusProcessor with MaxAutoLockRenewalDuration | Handles lock renewal automatically |
| Peek-Lock | Make processing idempotent | Messages may be delivered more than once if lock expires |
| Peek-Lock | Set MaxDeliveryCount appropriately | Controls how many retries before dead-lettering |
| All | Enable duplicate detection on queues | Prevents duplicates from forwarding retries |
Common Pitfalls
| Pitfall | Consequence | Fix |
|---|---|---|
| Circular auto-forwarding (A → B → A) | Configuration rejected by Service Bus | Design acyclic topologies |
| Forgetting to store deferred sequence numbers | Messages become permanently orphaned | Always persist sequence numbers before deferring |
| Lock duration too short for processing time | MessageLockLost exceptions, duplicate processing | Increase queue lock duration or use auto-renewal |
Not handling MessageLockLost | Unhandled exceptions crash the consumer | Catch and log; ensure idempotent processing |
| Auto-forwarding to a non-existent entity | Queue/subscription creation fails | Create destination entities first via IaC |
| Peeking and assuming message won't change | Another consumer may complete it between peek and receive | Use peek for auditing only, not for processing decisions |
| Deferring without TTL | Deferred messages accumulate indefinitely | Set TTL on messages and run cleanup jobs |
Monitoring
KQL Queries for Azure Monitor / Log Analytics
Track forwarded message volume:
AzureDiagnostics
| where ResourceProvider == "MICROSOFT.SERVICEBUS"
| where OperationName == "AutoForward"
| summarize ForwardedCount = count() by bin(TimeGenerated, 5m), EntityName = tostring(properties_s)
| render timechart
Monitor deferred message count:
AzureDiagnostics
| where ResourceProvider == "MICROSOFT.SERVICEBUS"
| where MetricName == "DeferredMessages"
| summarize MaxDeferred = max(Count) by bin(TimeGenerated, 5m), EntityName
| where MaxDeferred > 100
| render timechart
Detect lock expiration issues:
AzureDiagnostics
| where ResourceProvider == "MICROSOFT.SERVICEBUS"
| where OperationName == "Complete" and ResultType == "MessageLockLost"
| summarize LockLostCount = count() by bin(TimeGenerated, 15m), EntityName
| where LockLostCount > 5
| render timechart
Dead-letter queue growth (may indicate forwarding or processing failures):
AzureMetrics
| where ResourceProvider == "MICROSOFT.SERVICEBUS"
| where MetricName == "DeadletteredMessages"
| summarize MaxDLQ = max(Total) by bin(TimeGenerated, 5m), EntityName = tostring(split(ResourceId, "/")[-1])
| where MaxDLQ > 0
| render timechart
Azure Monitor Alerts (Bicep)
resource dlqAlert 'Microsoft.Insights/metricAlerts@2018-03-01' = {
name: 'servicebus-dlq-alert'
location: 'global'
properties: {
severity: 2
evaluationFrequency: 'PT5M'
windowSize: 'PT15M'
criteria: {
'odata.type': 'Microsoft.Azure.Monitor.SingleResourceMultipleMetricCriteria'
allOf: [
{
name: 'DLQThreshold'
metricName: 'DeadletteredMessages'
operator: 'GreaterThan'
threshold: 10
timeAggregation: 'Maximum'
}
]
}
scopes: [
serviceBusNamespace.id
]
}
}
Summary
| Pattern | When to Use | Key API |
|---|---|---|
| Auto-Forwarding | Routing, fan-out, pipeline stages | CreateQueueOptions.ForwardTo |
| Message Deferral | Out-of-order processing, sagas, dependency waiting | DeferMessageAsync / ReceiveDeferredMessageAsync |
| Peek-Lock | At-least-once delivery, safe processing, auditing | ReceiveMode.PeekLock / CompleteMessageAsync |
These three patterns form the backbone of reliable message processing in Azure Service Bus. Auto-forwarding handles routing without code, deferral manages temporal dependencies, and peek-lock ensures no message is lost during processing. Combined, they enable complex workflows that are both resilient and observable.