Result And Wait Are Deadlocks Waiting
This C# habit deadlocks web apps. One .Result in the middle of an async call chain works fine on your machine, works fine in the demo, then freezes a request thread in production. Under load it's worse: many blocked threads at once starve the thread pool and the whole service stops answering. Treat every .Result and .Wait as a deadlock waiting to happen.
Why one line can freeze a request
When you await an incomplete task, the current context is captured by default and used to resume the method. In classic ASP.NET and GUI apps, that context allows only one chunk of code at a time. Now block that same context thread with .Result: the await's continuation is queued to a context the blocking thread owns, and the blocking thread won't release it until the task finishes. Each side waits for the other — a classic deadlock. Stephen Cleary calls this the single most-asked question from async newcomers: "Why does my partially async code deadlock?"
The same code runs fine in a console app (thread-pool context, no deadlock), which is exactly why it survives local testing and dies in the web app.
ASP.NET Core removed that per-request context, so the textbook deadlock is rarer — but blocking is still poison there. Microsoft's own guidance is blunt: "Many synchronous blocking calls lead to Thread Pool starvation and degraded response times." A pool sized for thousands of concurrent async requests collapses when each request pins a thread waiting on .Result.
The fix: async in, async out
Asynchrony is viral. Once one call is async, every caller above it should be async too — "efforts to be async amount to nothing unless the entire call stack is async" (David Fowler's ASP.NET Core guidance). The translation table is short:
| Instead of… | Use |
|---|---|
task.Result / task.Wait() | await task |
Task.WaitAny(...) | await Task.WhenAny(...) |
Task.WaitAll(...) | await Task.WhenAll(...) |
Thread.Sleep(1000) | await Task.Delay(1000) |
Concretely:
// Deadlock (or starved thread) waiting to happen:
public IActionResult Get()
{
var data = _service.LoadAsync().Result; // blocks the request thread
return Ok(data);
}
// Async all the way down:
public async Task<IActionResult> Get(CancellationToken ct)
{
var data = await _service.LoadAsync(ct);
return Ok(data);
}
Note the CancellationToken: .NET's cooperative cancellation model expects you to accept a token and pass it through the whole call chain, so a client that disconnects (or a timeout) can actually stop the work instead of burning a thread on a response nobody will read.
Power-user notes
.Resultis worse than a truly synchronous API. Fowler's guidance rates sync-over-async as much worse than calling a genuinely synchronous method — you pay for the async machinery and block a thread.- Don't "fix" it with
Task.Run(...).Result. In ASP.NET Core, wrapping work inTask.Runjust adds extra thread-pool scheduling; your code already runs on pool threads. - Exceptions change shape when you block.
awaitre-throws the original exception;.Result/.Wait()wrap everything in anAggregateException, so yourcatch (InvalidOperationException)silently stops matching. ConfigureAwait(false)in libraries. It skips capturing the context, which sidesteps the classic deadlock and avoids needless context hops — use it in library code that doesn't touch UI orHttpContext.- Legit exceptions exist. A console
Main(pre-async Main) may block; almost nothing else should. - Diagnose starvation, don't guess. .NET ships a thread-pool starvation debugging guide using
dotnet-counters; a climbing thread count with flat throughput is the signature.
Resources
- Async/Await — Best Practices in Asynchronous Programming (Stephen Cleary, MSDN Magazine)
- ASP.NET Core Best Practices — Avoid blocking calls
- Async Guidance — David Fowler, AspNetCoreDiagnosticScenarios
- Cancellation in managed threads (.NET docs)
- Debug thread pool starvation (.NET diagnostics)
Building an AI feature? Yeda AI designs, audits, and ships production LLM systems.