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

8 min readAjay Patel

Microsoft introduced async/await way back in C# 5 alongside the Task Parallel Library (TPL), and it fundamentally changed how we write .NET applications. It made asynchronous programming so approachable and syntax-friendly that developers started sprinkling async and await absolutely everywhere.

But here is the painful reality: just because your code 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. The servers look healthy, CPU usage is weirdly low, but every single HTTP request is timing out. Almost every single time, the culprit is a fundamental misunderstanding of how the asynchronous state machine and the .NET Thread Pool actually work.

Here is an exhaustive guide on how C# manages asynchronous work, the specific async anti-patterns you are probably writing, and how to fix them today.

Table of Contents

1. The Async State Machine Explained

When you mark a method with the async modifier, the C# compiler performs a massive rewrite of your code behind the scenes. It transforms your method into an IAsyncStateMachine structure.

Every time you write await, the compiler splits your method into chunks. When the code reaches the await keyword, it fires off the I/O operation (like a database query or an HTTP request), and then returns the thread immediately to the Thread Pool.

The thread does not wait. It goes off to serve other web requests. When the database query finishes, an interrupt is fired, and a free thread from the pool picks up the state machine exactly where it left off, executing the remainder of your method.

This is why async/await allows a web server with only 50 threads to handle 50,000 concurrent connections. The threads are never sitting idle waiting for network packets; they are always actively processing CPU instructions.

(For a deeper dive on server performance, check out my guide on .NET 8 Native AOT).

2. Anti-Pattern 1: Async Void (The Silent Killer)

If you have a method in your codebase 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 dangerous? Because it completely circumvents the standard exception-handling mechanism.

When an async Task method throws an exception, the exception is safely bundled inside the returned Task object. You can catch it. But async void returns nothing. If _emailClient.SendAsync throws a network exception, it cannot be caught by any surrounding try/catch block.

Instead, the exception escapes the method and is thrown directly onto the SynchronizationContext. In modern ASP.NET Core, an unhandled exception on the synchronization context will abruptly crash the entire process.

async void should only ever be used for UI event handlers (like button clicks in WPF/WinForms where the framework expects a void return type). In web applications, if you want a true "fire and forget" background task, you should be using a hosted BackgroundService or a dedicated job queue like Hangfire.

If you must write a method that returns nothing, always return Task:

// ✅ Safe. The exception is contained in the Task.
public async Task FireAndForgetEmail()
{
    await _emailClient.SendAsync("hello@world.com");
}

3. Anti-Pattern 2: Sync-over-Async (.Result and .Wait)

I covered this briefly in my ASP.NET Core Middleware Guide, but it warrants an entire section. Mixing synchronous code with asynchronous code is the absolute fastest way to bring down a production server.

// 🚨 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 call .Result or .Wait() on a Task that has not completed, you synchronously block the current thread until the background operation finishes.

Remember the state machine? The thread doesn't return to the Thread Pool. It just sits there, completely paralyzed, waiting for the database to return data. If your site gets a spike in traffic, ASP.NET Core will quickly exhaust all available threads. This is known as Thread Pool Starvation, and your app will appear completely dead to the outside world.

The Golden Rule: It is async all the way down. If a deep repository method calls an async database query, every single method in the call stack above it must be updated to be async Task<T>.

4. Anti-Pattern 3: Returning Task vs Awaiting Task

This one is subtle. Sometimes developers try to be clever and omit the async and await keywords to save the compiler from generating the state machine overhead:

public Task<User> GetUserAsync(int id)
{
    // No 'await', just returning the Task directly
    return _dbContext.Users.FindAsync(id); 
}

This is generally fine, and technically performs slightly faster. However, you lose two critical framework features by omitting the await keyword:

  1. Using Blocks: If that DbContext is disposed in a using block inside this method, it will dispose immediately before the asynchronous database query finishes. This will throw an ObjectDisposedException asynchronously.
  2. Stack Traces: If the returned task throws an exception, the stack trace will not include this method! The exception will appear to jump straight from the data layer to the controller, skipping your service layer entirely. This makes debugging incredibly frustrating.

Unless you are writing an ultra-low-latency Minimal API where saving a few allocations per second actually matters, just await it. The overhead of the state machine in modern .NET is negligible compared to the headache of losing your stack trace.

5. Anti-Pattern 4: Forgetting ConfigureAwait(false)

If you are writing a NuGet package or a reusable class library, you should be appending .ConfigureAwait(false) to your awaits.

public async Task DoWorkAsync()
{
    await SomeExternalCallAsync().ConfigureAwait(false);
}

In older frameworks (ASP.NET MVC 5, WPF, WinForms), when an await completes, it tries to marshal the rest of the method's execution back onto the original thread (like the UI thread). If that UI thread is currently blocked, you create a classic deadlock.

.ConfigureAwait(false) tells the runtime, "I don't care what thread resumes this method, just pick any background thread from the pool." This prevents deadlocks entirely.

Note: In modern ASP.NET Core, Microsoft removed the SynchronizationContext entirely. Because of this, ConfigureAwait(false) actually does nothing in a pure ASP.NET Core app. However, if you are writing shared libraries that might be consumed by UI apps or legacy .NET Framework codebases, it is still a mandatory best practice.

6. Anti-Pattern 5: Async in Constructors

Constructors are inherently synchronous. You cannot use the await keyword inside a constructor.

Developers often try to work around this by doing something terrible like:

public class ConfigurationService
{
    public ConfigurationService()
    {
        // 🚨 Blocking the constructor synchronously!
        LoadConfigAsync().Wait(); 
    }
}

Do not block in constructors. If your object requires asynchronous initialization (like fetching config from Azure KeyVault), use the Asynchronous Factory Pattern:

public class ConfigurationService
{
    private ConfigurationService() { } // Private constructor

    public static async Task<ConfigurationService> CreateAsync()
    {
        var service = new ConfigurationService();
        await service.LoadConfigAsync(); // Await safely
        return service;
    }
}

7. Task.Run vs Task.Factory.StartNew

If you need to offload heavy CPU-bound work (like image processing or complex math) to a background thread, you should always use Task.Run().

// ✅ Good
await Task.Run(() => ComputeHeavyMath());

Do not use Task.Factory.StartNew(). It is an older, much lower-level API that does not automatically unwrap asynchronous delegates. If you accidentally pass an async lambda to StartNew, it will return a Task<Task>, and if you only await it once, you will accidentally fire-and-forget the inner work, leading to silent failures. Task.Run is designed to handle this safely.

Final Thoughts

Asynchronous programming is a remarkably 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 entirely with async void, that knife is going to cut you.

Turn on your IDE analyzers, treat compiler warnings as errors, and enforce async best practices in your team's code reviews.

Tired of writing boilerplate classes? If you find yourself constantly mapping DTOs for your async HTTP endpoints, stop doing it by hand. Check out my completely free JSON to C# Converter to instantly generate your C# models right in your browser. No strings attached, 100% privacy-first.

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