TL;DR: The async model is a contract. In .NET, async methods are expected to do little CPU work, spend most of their time awaiting input/output, and make long-running CPU work explicit. Lease renewal depends on that contract. If a handler hides blocking or CPU-heavy work inside an async method, the scheduler assumptions behind renewal can fall apart. The message pump depends on the broker API, but it also depends on the runtime’s execution model.
After publishing the previous article, Adam Furmanek pointed out a detail I had mostly treated as background noise. The lease renewal pattern assumes the renewal task continues making progress while the handler executes.
That observation sent me down another rabbit hole because the problem is not mainly about Azure Queue Storage. It is about the contract between asynchronous code and the runtime that schedules it.
The previous article treated renewal as a message pump responsibility. Receive the message, start the handler, start a renewer, keep the lease alive, and coordinate completion. That is still the right place for the responsibility to live.
But Adam’s point exposes another layer underneath that design. The handler and the renewer are not magic. They both need an execution engine that gives them time to run.
If the renewer does not get scheduled before the lease expires, the message can become visible again. It does not matter that the code has a renewal loop. It does not matter that the delay was calculated carefully. The renewal task has to make progress, and progress depends on the handler keeping its side of the async contract.
Two tasks are sharing more than a process
The simple mental model has two activities for one in-flight message. One runs the business handler. The other renews the lease.
The diagram helps, but it leaves something out. Those two activities share an execution engine.
In .NET, that usually means tasks, continuations, timers, and thread pool work items. In another runtime, it might mean one event loop, cooperative green threads, virtual threads, goroutines, or something else entirely. The names change, but the pump still depends on one question:
What guarantees does the scheduler make when one activity is slow, blocking, or CPU hungry?
The discussion stops being mainly about Azure Queue Storage here. Lease renewal is a distributed systems problem, but the implementation also depends on the local execution model.
That gives the series a nice shape. The first articles moved from business code down into the message pump. This one moves one layer lower again:
Business handler
│
Message pump
│
Async runtime / scheduler
│
Operating system
Each layer hides complexity from the one above it. Each layer also leaks when its assumptions are violated.
The async model is a contract
It is tempting to look at code like this and think the renewer is independent of the handler:
Task handler = HandleMessage(message, token);
Task renewer = RenewLeaseUntilHandlerCompletes(message, handler, token);
await handler;
await renewer;
They are separate tasks, but they are not separate universes. Both tasks depend on the runtime to resume them. A delay completes. A timer fires. A network call finishes. A continuation needs to run somewhere.
The .NET async programming model is built around a simple expectation: async methods should do little CPU work, spend most of their time awaiting input/output, and make long-running CPU work explicit. When handlers follow that expectation, they regularly return control to the runtime. While one handler waits for a database, the file system, or an external service, the scheduler can run other work. The renewer can wake up and extend the lease.
The scheduler can only keep its promises if the handler keeps its promises too.
This is undoubtedly why one of the classic async guidelines is to distinguish CPU-bound work from input/output-bound work. Lucian Wischik makes that distinction in Six Essential Tips for Async: async and await are designed to suspend efficiently while waiting for external resources. Long-running CPU work has different scheduling requirements and should use an execution strategy appropriate for CPU-bound work instead of pretending to be asynchronous input/output.
I used to think of that mainly as throughput and scalability advice. Adam’s observation made me appreciate a second reason. The distinction also preserves scheduler assumptions that infrastructure like lease renewal depends on.
Now put blocking work inside the handler:
async Task HandleMessage(OrderSubmitted message, CancellationToken token) {
Thread.Sleep(TimeSpan.FromSeconds(30));
await SaveInvoice(message, token);
}
The method still says async. That does not make the sleep asynchronous. Strictly speaking, async is not part of the .NET method signature; callers see a Task-returning method, usually named with an Async suffix by convention. But that convention still carries an expectation. As Lucian Wischik puts it, async methods should not lie. A Task-returning method named Async should not hide long-running blocking work behind an asynchronous-looking API. A thread is occupied for 30 seconds. Enough handlers doing that force the scheduler into recovery mode, injecting additional worker threads to maintain progress. Lease renewal still depends on that recovery happening quickly enough.
The .NET thread pool is not passive. It has mechanisms for starvation avoidance and cooperative blocking compensation that can inject additional worker threads when progress is at risk. That makes this failure mode much less common in practice than in execution models that rely on a single event loop or purely cooperative scheduling. The guidance still exists because thread injection is a recovery mechanism, not a substitute for correctly separating CPU-bound and asynchronous work.
The base class libraries have similar scars. Current SocketsHttpHandler explicitly queues potentially blocking first-use handler setup to the thread pool because proxy setup can block for seconds in some environments. The source comment says the setup procedure is enqueued to prevent the caller from blocking. David Fowler’s async guidance makes the same point from another angle: TaskCompletionSource continuations can run inline by default, so library code often uses RunContinuationsAsynchronously when resuming callers inline could cause deadlocks, starvation, or unexpected reentrancy. These are targeted mitigations. They do not remove the contract from application code; they show how much work library authors do to preserve it.
This is true of most abstractions. A database connection pool assumes callers eventually return connections. A semaphore assumes callers eventually release permits. An async scheduler assumes code eventually yields.
CPU-bound work has a similar shape. This handler is asynchronous only at the end:
async Task HandleMessage(ReportRequested message, CancellationToken token) {
Render1000PagePdf(message);
await UploadReport(message, token);
}
The expensive part runs inline on the scheduler resource that is also needed by other work. A better shape makes the CPU-bound part explicit and bounded:
async Task HandleMessage(ReportRequested message, CancellationToken token) {
await reportRenderer.Render1000PagePdf(message, token);
await UploadReport(message, token);
}
The renderer might use a bounded worker pool, dedicated workers, or another explicit CPU-bound execution strategy internally. In a small application, the boundary might even be as simple as await Task.Run(() => Render1000PagePdf(message), token), as long as concurrency is still controlled. Or it may execute on a dedicated bounded execution resource. The message pump should stop pretending the whole handler is ordinary asynchronous input/output.
In that situation, lease renewal is not broken. The async contract has been violated, and the scheduler assumptions behind renewal no longer hold.
Different runtimes expose different failure modes
Adam’s observation pushes the conversation out of one broker and one client library. A renewal loop that works well under one execution model can fail under another if the progress contract is different or if the application violates that contract.
Different execution models expose different failure modes. A cooperative event loop, a thread-pool scheduler, virtual threads, and goroutines all make different trade-offs. None remove the need for progress. They merely provide different mechanisms for achieving it.
That does not make one model good and another bad. A message pump has to understand the model it is built on. Instead of asking “which runtime is better?” ask “what must my code do so the runtime can keep making progress?”
For the message pump, the practical question is simple: under load, can the lease renewal activity run before the lease expires?
If the answer depends on every handler respecting the async contract, the pump has an operational risk. It may work in tests, pass a happy-path demo, and still lose leases during a production incident when one dependency stalls or one code path burns CPU.
The pump should separate work by scheduling needs
This is not an argument against async. It is an argument against pretending all work has the same scheduling needs.
Input/output-bound handlers fit the asynchronous model well. They spend much of their lifetime waiting for other systems, and await gives the runtime natural points to run other work. Lease renewal can usually share that execution model safely.
Blocking and CPU-bound handlers need a different design. That may mean moving the CPU-heavy part behind a bounded worker pool, using dedicated threads, applying explicit parallelism, reducing message pump concurrency, or splitting the work so the pump does not hold a short broker lease while a long local computation runs.
For a message pump, this is not an implementation detail. It changes the reliability contract.
A pump that accepts arbitrary handlers should assume some of them will block, burn CPU, or ignore cancellation. If renewal shares the same exhausted execution resources as those handlers, the pump can lose ownership of messages while the process is still healthy enough to keep doing damage.
That is why production pumps often grow boundaries that are easy to dismiss in the first version: concurrency limits, timeout policies, cancellation propagation, dedicated coordination state, health checks, and telemetry for lock loss or renewal delay. Those features are not decoration. They are how the pump turns scheduler assumptions into observable behavior.
A small note about runtime async
The .NET runtime is still evolving here. .NET 11 introduces Runtime Async as a preview feature, moving more async suspension and resumption machinery into the runtime. That can improve live stack traces, debugging, overhead, and diagnostics.
It is worth watching, but it does not change the main point. Better async machinery does not turn blocking work into asynchronous work. It does not make expensive CPU work disappear. The contract still matters.
Renewal is a scheduling promise
What I like about Adam’s observation is that it moved the discussion one abstraction layer down.
My previous article argued that lease renewal belongs inside the message pump.
This article argues something slightly different.
The message pump itself sits on top of another abstraction: the runtime’s execution model.
A renewal loop is not merely a timer. It is a promise that the runtime can continue making progress while business code is still executing.
That promise depends on both the pump and the scheduler honoring their respective contracts.
Reliable messaging is usually presented as a broker problem. Adam’s observation reminds me that it is also a runtime problem. A message pump is only as reliable as the scheduler that keeps it making progress.
Further reading:
- Adam Furmanek, Async Wandering Part 15
- Scooletz, Async Reloaded presentation materials
- Lucian Wischik, Six Essential Tips for Async
- Lucian Wischik, Tip 2: Distinguish CPU-bound work from IO-bound work
- Lucian Wischik, Tip 4: Async Library Methods Shouldn’t Lie
- David Fowler, Async Guidance
- dotnet/runtime issue 115301: HttpClient async methods are blocking the first time
- dotnet/runtime PR 115688: Added an additional yield point prior to possibly long running initialization
- .NET 11 Runtime Async documentation
- .NET runtime async design draft