← Back to ArticlesService Bus

Service Bus — Auto-Forwarding, Message Deferral, and Peek-Lock Patterns

Implementing auto-forwarding between queues, message deferral for later processing, and peek-lock patterns in Azure Service Bus.

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:

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:

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:


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:

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

RuleDetail
Same namespaceSource and destination must be in the same namespace
Entity must existDestination entity must exist when configuring forwarding
No circular chainsA → B → A is not allowed; Service Bus rejects circular configurations
Max chain depth4 hops maximum
SessionsForwarded messages lose session affinity unless destination is also session-enabled
PartitioningBoth source and destination must have the same partitioning configuration
Dead-letter forwardingYou can also set ForwardDeadLetteredMessagesTo to route DLQ messages

Error Handling When Destination Is Full

When the destination entity reaches its maximum size:

  1. New messages to the source are rejected — Senders receive a QuotaExceededException.
  2. Messages already in the source stay there — They won't be forwarded until space is available.
  3. 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:

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:

ModeBehaviorRiskUse Case
ReceiveAndDeleteMessage removed immediately on receiveMessage lost if consumer crashesHigh-throughput, idempotent workloads
PeekLockMessage locked but not removed; must be explicitly completedSlower, but no data lossBusiness-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

PatternBest PracticeWhy
Auto-ForwardingMonitor destination queue sizePrevents forwarding failures when destination is full
Auto-ForwardingKeep chain depth ≤ 3 hopsReduces complexity and debugging difficulty
Auto-ForwardingUse dead-letter forwarding (ForwardDeadLetteredMessagesTo)Centralizes poison message handling
DeferralAlways store sequence numbers externallyDeferred messages are unreachable without them
DeferralImplement timeout cleanupPrevents unbounded growth of deferred messages
DeferralSet message TTLEnsures deferred messages eventually expire
Peek-LockUse ServiceBusProcessor with MaxAutoLockRenewalDurationHandles lock renewal automatically
Peek-LockMake processing idempotentMessages may be delivered more than once if lock expires
Peek-LockSet MaxDeliveryCount appropriatelyControls how many retries before dead-lettering
AllEnable duplicate detection on queuesPrevents duplicates from forwarding retries

Common Pitfalls

PitfallConsequenceFix
Circular auto-forwarding (A → B → A)Configuration rejected by Service BusDesign acyclic topologies
Forgetting to store deferred sequence numbersMessages become permanently orphanedAlways persist sequence numbers before deferring
Lock duration too short for processing timeMessageLockLost exceptions, duplicate processingIncrease queue lock duration or use auto-renewal
Not handling MessageLockLostUnhandled exceptions crash the consumerCatch and log; ensure idempotent processing
Auto-forwarding to a non-existent entityQueue/subscription creation failsCreate destination entities first via IaC
Peeking and assuming message won't changeAnother consumer may complete it between peek and receiveUse peek for auditing only, not for processing decisions
Deferring without TTLDeferred messages accumulate indefinitelySet 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

PatternWhen to UseKey API
Auto-ForwardingRouting, fan-out, pipeline stagesCreateQueueOptions.ForwardTo
Message DeferralOut-of-order processing, sagas, dependency waitingDeferMessageAsync / ReceiveDeferredMessageAsync
Peek-LockAt-least-once delivery, safe processing, auditingReceiveMode.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.