The relationship between MaxDeliveryCount and MaxConcurrentCalls nobody explains
With 3 replicas and MaxConcurrentCalls = 4, one message can hit MaxDeliveryCount before any replica finishes.
Three weeks in. Service Bus week. Something I have not seen explained cleanly in any documentation:
MaxDeliveryCount counts delivery attempts across all consumer replicas and across all time — not just the current processing attempt.
Here is the scenario:
You have 3 OrderProcessor replicas, each with MaxConcurrentCalls = 4. That is 12 concurrent handler slots across the deployment. MaxDeliveryCount on the queue is set to 3.
A message arrives. Replica 1 picks it up. The handler starts. The message lock duration is 30 seconds. The handler is slow — it is calling a downstream API with no timeout configured. After 30 seconds, the lock expires. Service Bus abandons the message: DeliveryCount = 2. Replica 2 picks it up immediately (it is available and a slot is free). Replica 2’s handler also stalls. Another 30 seconds. DeliveryCount = 3. Replica 3 picks it up. Any failure now — exception, timeout, or lock expiry — sends this message straight to the DLQ. Three attempts. Three replicas. One message. Dead-lettered before any replica finished.
This is why MaxAutoLockRenewalDuration matters as much as MaxDeliveryCount.
If your handler can take up to 10 minutes under load, set: MaxAutoLockRenewalDuration = TimeSpan.FromMinutes(15)
The SDK renews the lock automatically before it expires. No lock expiry. No unintended DeliveryCount increments. DeliveryCount only goes up when your handler actually throws or you call AbandonMessageAsync explicitly.
The rule: MaxAutoLockRenewalDuration > longest possible handler duration MaxDeliveryCount = number of retry attempts you actually want
Post 8 of the series covers the full backpressure picture — including what MaxConcurrentCalls does to throughput at different handler latencies.
───────────────────────────────────────────────────── THREE THINGS WORTH YOUR TIME THIS WEEK ─────────────────────────────────────────────────────
Message lock renewal in Azure Service Bus https://learn.microsoft.com/en-us/azure/service-bus-messaging/message-transfers-locks-settlement#lock-renewal The section on automatic lock renewal and what happens if the renewal itself fails (network partition, process restart mid-renewal). Worth understanding before you configure MaxAutoLockRenewalDuration in production.
The .NET BackgroundService cancellation token contract https://learn.microsoft.com/en-us/dotnet/core/extensions/workers The StopAsync cancellation token is what stops ServiceBusProcessor.StopProcessingAsync() gracefully. If you do not honour it in your handler, messages can be abandoned on shutdown and their DeliveryCount incremented unnecessarily.
Azure Service Bus best practices — Microsoft’s official guidance https://learn.microsoft.com/en-us/azure/service-bus-messaging/service-bus-performance-improvements The section on prefetch count. ServiceBusProcessorOptions.PrefetchCount is not covered in Post 8 — it is a throughput optimisation that can interact unexpectedly with MaxConcurrentCalls. Worth reading before tuning in production.
──────────────────────────────────────────────────────ONE QUESTION ──────────────────────────────────────────────────────
Three weeks in and you have seen Phase 1 (Aspire + OTel + App Insights) and the start of Phase 2 (Service Bus serialisation and backpressure).
What comes next in the plan: DLQ forensics, Polly resilience, Event Hubs.
Which of these does your team deal with most in production right now?
A) DLQ — diagnosing and replaying dead-lettered messages B) Resilience — retry policies, timeouts, circuit breakers C) Event Hubs — streaming vs queuing decisions D) Something in Phase 1 you want more depth on E) None of these — hit reply and tell me
Hit reply. Shapes the depth of Phase 2 posts.
─────────────────────────────────────────────────────
Talk Sunday,
Distributed Systems with .NET distrib-dotnet.substack.com
This week: Posts 7–8 published (paid). Posts 1–3 free to all.


