Async/Await in C#: The Deadlocks You Are Probably Creating
Microsoft introduced async/await way back in C# 5, and it fundamentally changed how we write .NET applications. It made asynchronous programming so approachable that developers started sprinkling async and await absolutely everywhere.
But here is the painful reality: just because it compiles doesn't mean it's right.
I've been called into dozens of "emergency architecture reviews" because an API suddenly grinds to a halt under load. Almost every single time, the culprit is a fundamental misunderstanding of how the async state machine works.
Here are the specific async anti-patterns you need to stop writing today.
1. Async Void (The Silent Killer)
If you have a method that looks like this, stop reading and go fix it immediately:
// 🚨 Absolute nightmare fuel
public async void FireAndForgetEmail()
{
await _emailClient.SendAsync("hello@world.com");
}
Why is async void so bad? Because it circumvents the standard exception handling mechanism. If _emailClient.SendAsync throws an exception, it won't be caught by any surrounding try/catch block in the calling code. Instead, the exception is thrown directly onto the SynchronizationContext, which in ASP.NET Core, will crash the entire process.
async void should only ever be used for event handlers (like UI button clicks in WPF/WinForms). In web applications, if you want a true "fire and forget" task, you should be using a BackgroundService or something like Hangfire.
If you must return nothing, return Task:
// ✅ Safe
public async Task FireAndForgetEmail()
{
await _emailClient.SendAsync("hello@world.com");
}
2. Sync-over-Async (.Result and .Wait())
I covered this in my middleware article, but it warrants repeating. Mixing synchronous code with asynchronous code is how you create deadlocks.
// 🚨 Say goodbye to your thread pool
public User GetUser(int id)
{
// Calling .Result on an uncompleted Task blocks the current thread
return _dbContext.Users.FindAsync(id).Result;
}
If you block a thread while waiting for an async operation to complete, you are tying up a thread pool thread doing absolutely nothing. If your site gets a spike in traffic, you will hit thread exhaustion, and your app will stop responding entirely.
The Rule: It's async all the way down. If a method calls an async database query, the method itself must be async Task<T>.
3. Returning Task vs Awaiting Task
This one is subtle. Sometimes developers try to be clever and omit the async/await keywords if they are just passing a Task through:
public Task<User> GetUserAsync(int id)
{
// No 'await', just returning the Task directly
return _dbContext.Users.FindAsync(id);
}
This is generally fine and saves the compiler from generating a state machine. However, you lose two things:
- Using blocks: If that DbContext is disposed in a
usingblock inside this method, it will dispose before the Task completes, causing anObjectDisposedException. - Stack Traces: If the task throws an exception, the stack trace will not include this method, making debugging a nightmare.
If in doubt, just await it. The overhead of the state machine in modern .NET is incredibly negligible compared to the headache of losing your stack trace.
4. Forgetting ConfigureAwait(false) (For Library Authors)
If you are writing a NuGet package or a class library, you should be appending .ConfigureAwait(false) to your awaits.
public async Task DoWorkAsync()
{
await SomeExternalCallAsync().ConfigureAwait(false);
}
In older versions of ASP.NET (pre-Core) and UI apps, await tries to marshal the continuation back onto the original context (like the UI thread). This is a primary cause of deadlocks. .ConfigureAwait(false) tells the runtime, "I don't care what thread resumes this method, just pick a background thread."
Note: In ASP.NET Core, there is no SynchronizationContext, so ConfigureAwait(false) actually does nothing. But it's still best practice if your code might be used outside of ASP.NET Core.
Final Thoughts
Asynchronous programming is a sharp knife. It allows ASP.NET Core to handle tens of thousands of concurrent requests with just a handful of threads. But if you block those threads with .Result, or crash the process with async void, that knife is going to cut you.
Turn on your IDE analyzers, treat compiler warnings as errors, and keep your async chains clean.