/**/

Async/Await in C#: The Deadlocks You Are Probably Creating

4 min readAjay Patel

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:

  1. Using blocks: If that DbContext is disposed in a using block inside this method, it will dispose before the Task completes, causing an ObjectDisposedException.
  2. 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.

About the Author

Ajay Patel

Software Developer specializing in .NET, C#, and ASP.NET Core. Built .NET Toolbox to give developers privacy-first, browser-based utilities for their daily workflow.

Read more about Ajay