TL;DR: Mixing
asyncand synchronous code with.Resultwill crash your app. Useasyncall the way down. UseConfigureAwait(false)in library code, but avoid it in ASP.NET Core apps. Stop usingasync voidunless it's a UI event handler, and always useCancellationTokenfor scaling web APIs.
We've all been there: you push a new feature to production, traffic spikes, and suddenly your ASP.NET Core application comes grinding to a halt. Threads are exhausted, requests are timing out, and the CPU is barely being used.
The culprit? Improper use of async and await.
Asynchronous programming in C# is a superpower, but it's incredibly easy to misuse. In this guide, we'll break down the most dangerous anti-patterns and how to write production-grade async C# in .NET 9.
1. The Deadly Sin: Sync over Async
The golden rule of asynchronous programming is: Once you go async, the entire call stack must be async.
Attempting to bridge synchronous and asynchronous code by blocking the thread with .Result or .Wait() is the number one cause of deadlocks in legacy ASP.NET (and massive thread-pool starvation in ASP.NET Core).
❌ The Bad Way
// This blocks the calling thread while waiting for the async operation.
// Under heavy load, this leads to Thread Pool starvation.
public User GetUser(int id)
{
var user = _dbContext.Users.FindAsync(id).Result; // BAD!
return user;
}
✅ The Good Way
// The thread is yielded back to the pool while the database executes the query.
public async Task<User> GetUserAsync(int id)
{
var user = await _dbContext.Users.FindAsync(id); // GOOD!
return user;
}
2. The Plague of async void
If there is one thing you take away from this article, let it be this: Never use async void.
When an exception is thrown inside an async Task method, the exception is safely caught and placed on the Task object for you to handle. When an exception is thrown inside an async void method, it completely bypasses standard try/catch blocks and crashes the entire application process.
The only exception to this rule is UI event handlers (like a button click in WPF or MAUI).
❌ The Bad Way
public async void ProcessPayment(Order order)
{
// If this throws, your entire process crashes. No catch block can save you.
await _paymentGateway.ChargeAsync(order);
}
✅ The Good Way
public async Task ProcessPaymentAsync(Order order)
{
// The exception is safely bundled inside the returned Task.
await _paymentGateway.ChargeAsync(order);
}
3. Mastering ConfigureAwait(false)
If you are writing a reusable NuGet package or a class library, you should almost always append .ConfigureAwait(false) to your await calls.
By default, await attempts to marshal the continuation back to the original SynchronizationContext (like the UI thread). Library code doesn't care what thread it resumes on, so skipping this context switch saves performance and prevents deadlocks in legacy apps.
[!NOTE] ASP.NET Core Exception: ASP.NET Core does not have a SynchronizationContext. Therefore, using
ConfigureAwait(false)inside your ASP.NET Core controllers or minimal APIs is redundant and just adds noise.
// Inside a class library (Nuget package):
public async Task<string> FetchDataAsync()
{
// GOOD: Avoids forcing the continuation back to the UI thread
var response = await _httpClient.GetAsync("https://api.com").ConfigureAwait(false);
return await response.Content.ReadAsStringAsync().ConfigureAwait(false);
}
4. Always Pass the CancellationToken
When a user closes their browser or navigates away from your site, ASP.NET Core knows about it. However, if your API is currently querying a database or calling a third-party API, that work will continue executing in the background, wasting precious server resources.
You must pass the CancellationToken down through your entire execution pipeline.
[HttpGet("{id}")]
public async Task<IActionResult> GetOrder(int id, CancellationToken cancellationToken)
{
// If the user cancels the request, the database query aborts instantly.
var order = await _dbContext.Orders
.FirstOrDefaultAsync(o => o.Id == id, cancellationToken);
return Ok(order);
}
Final Thoughts
Writing scalable async code in .NET 9 doesn't require a Ph.D. in computer science. By consistently returning Task, avoiding .Result, and respecting cancellation tokens, your APIs will scale beautifully to thousands of concurrent users.