Async/Await Pitfalls: The Deadlocks That Ship to Production

TL;DR The Task.Result calls, missing ConfigureAwait, and async void patterns that cause production deadlocks in .NET applications.

Async/await in C# is deceptively simple. The syntax looks synchronous. The bugs are anything but.

These are the patterns that compile, pass code review, work in development, and then deadlock in production.

Common questions this answers

  • Why does my async code deadlock when I call .Result or .Wait()?
  • When should I use ConfigureAwait(false)?
  • What is wrong with async void methods?
  • How is ASP.NET Core different from legacy ASP.NET for async?
  • How do I detect sync-over-async problems before they ship?

Definition (what this means in practice)

Async/await deadlocks occur when synchronous code blocks on asynchronous operations in a context that requires continuation on a specific thread. The blocking call waits for the async operation to complete. The async operation waits to resume on the blocked thread. Neither can proceed.

In practice, this means understanding synchronization contexts, using async consistently throughout the call stack, and knowing when ConfigureAwait matters.

Terms used

  • SynchronizationContext: a mechanism that controls where async continuations resume. GUI apps and legacy ASP.NET have contexts that marshal work to specific threads.
  • ConfigureAwait(false): instructs the await to not capture the current context, allowing continuation on any thread pool thread.
  • Async void: an async method returning void instead of Task. Exceptions cannot be caught and the caller cannot await completion.
  • Sync-over-async: calling .Result, .Wait(), or .GetAwaiter().GetResult() on a Task, blocking the calling thread.

Reader contract

This article is for:

  • Engineers maintaining async code in production.
  • Reviewers checking for async deadlock patterns.

You will leave with:

  • Understanding of why Task.Result causes deadlocks (and when it does not).
  • Clear guidance on ConfigureAwait for libraries vs applications.
  • Detection patterns for async void and sync-over-async.

This is not for:

  • Async beginners (assumes working knowledge of async/await).
  • Parallelism deep dives (Parallel.ForEach, channels, dataflow).

Quick start (10 minutes)

If you do nothing else, do this:

Verified on: ASP.NET Core (.NET 10).

  1. Search your codebase for .Result, .Wait(), and .GetAwaiter().GetResult(). Each occurrence is a potential deadlock.
  2. Search for async void. Every occurrence outside of event handlers is a bug waiting to happen.
  3. If you maintain a library, ensure every await uses ConfigureAwait(false).
  4. If you maintain an ASP.NET Core application, you likely do not need ConfigureAwait(false) but sync-over-async is still dangerous.
  5. Enable async analyzers to catch these patterns at build time.
<!-- Add to your .csproj to catch async issues at build time -->
<ItemGroup>
  <PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.*" />
</ItemGroup>

The classic Task.Result deadlock

This is the deadlock that has crashed thousands of production systems.

The problem

// Legacy ASP.NET or GUI application
public class DeadlockDemo
{
    public string GetData()
    {
        // DEADLOCK: blocks the context thread waiting for async completion
        return GetDataAsync().Result;
    }

    private async Task<string> GetDataAsync()
    {
        await Task.Delay(1000);
        // Tries to resume on the original context thread
        // But that thread is blocked waiting for this Task
        return "data";
    }
}

Why it deadlocks

The mechanics:

  1. GetData() calls GetDataAsync() and blocks on .Result.
  2. GetDataAsync() awaits Task.Delay(1000).
  3. By default, await captures the current SynchronizationContext.
  4. When the delay completes, the continuation tries to resume on the original context.
  5. The original context is a single-threaded context (GUI thread or legacy ASP.NET request thread).
  6. That thread is blocked on .Result, waiting for GetDataAsync() to complete.
  7. Deadlock: the continuation waits for the thread; the thread waits for the continuation.

The fix

Do not block on async code. Let async propagate through the call stack:

// GOOD: Async all the way
public async Task<string> GetDataAsync()
{
    var data = await FetchDataAsync();
    return data;
}

// If you must bridge sync and async (last resort):
public string GetData()
{
    // Only safe in console apps or contexts without SynchronizationContext
    return Task.Run(async () => await GetDataAsync()).GetAwaiter().GetResult();
}

ASP.NET Core difference

ASP.NET Core does not have a SynchronizationContext. This means .Result and .Wait() do not cause the classic deadlock in ASP.NET Core request handlers.

However, they still block a thread pool thread. Under load, this causes thread pool starvation, which manifests as requests timing out and the application becoming unresponsive. The symptom is different from a deadlock, but the result is the same: production outage.

// In ASP.NET Core: no deadlock, but thread pool starvation under load
[HttpGet]
public IActionResult Get()
{
    // BAD: Blocks a thread pool thread
    var data = GetDataAsync().Result;
    return Ok(data);
}

// GOOD: Non-blocking
[HttpGet]
public async Task<IActionResult> Get()
{
    var data = await GetDataAsync();
    return Ok(data);
}

ConfigureAwait: when and why

ConfigureAwait(false) tells the await not to capture the current SynchronizationContext. The continuation can run on any thread pool thread.

Library guidance

Libraries should use ConfigureAwait(false) on every await:

// Library code
public async Task<string> FetchDataAsync()
{
    var response = await _httpClient.GetAsync(url).ConfigureAwait(false);
    var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
    return content;
}

Why: library consumers may call your code from GUI apps or legacy ASP.NET. Without ConfigureAwait(false), your library inherits their SynchronizationContext and becomes part of their deadlock.

Application guidance

ASP.NET Core applications do not have a SynchronizationContext. ConfigureAwait(false) has no effect in ASP.NET Core request handlers.

However, if your ASP.NET Core application:

  • Starts background threads that create their own context
  • Uses third-party libraries that install a context
  • Hosts code that might run in other contexts (shared libraries)

...then ConfigureAwait(false) is still relevant.

When NOT to use ConfigureAwait(false)

When you need the original context:

// GUI application - need to update UI after await
private async void OnButtonClick(object sender, EventArgs e)
{
    button.Enabled = false;

    // Do NOT use ConfigureAwait(false) here - need UI context
    var data = await FetchDataAsync();

    // This runs on the UI thread because we captured context
    label.Text = data;
    button.Enabled = true;
}

If you use ConfigureAwait(false) before the UI update, the continuation runs on a thread pool thread, and the UI update throws because you are not on the UI thread.

Why async void is dangerous in C#

Async void methods are dangerous outside of event handlers.

The problem

// BAD: async void
public async void ProcessOrderAsync(Order order)
{
    await SaveOrderAsync(order);
    await SendConfirmationAsync(order);
}

// Caller has no way to know when this completes
// Caller has no way to catch exceptions
public void HandleOrder(Order order)
{
    ProcessOrderAsync(order);
    // Execution continues immediately
    // If ProcessOrderAsync throws, the exception crashes the process
}

Why it is dangerous

  1. Exceptions in async void methods cannot be caught. They propagate to the SynchronizationContext and typically crash the process.
  2. Callers cannot await completion. There is no Task to wait on.
  3. Unit testing is difficult. You cannot await the method or catch exceptions.

The fix

Return Task instead of void:

// GOOD: returns Task
public async Task ProcessOrderAsync(Order order)
{
    await SaveOrderAsync(order);
    await SendConfirmationAsync(order);
}

// Caller can await and catch exceptions
public async Task HandleOrderAsync(Order order)
{
    try
    {
        await ProcessOrderAsync(order);
    }
    catch (Exception ex)
    {
        // Handle exception properly
        _logger.LogError(ex, "Failed to process order {OrderId}", order.Id);
    }
}

Event handlers exception

Async event handlers must return void because the delegate signature requires it:

// This is the one acceptable use of async void
private async void OnButtonClick(object sender, EventArgs e)
{
    await ProcessAsync();
}

Even here, wrap the body in try-catch because exceptions will crash the application:

private async void OnButtonClick(object sender, EventArgs e)
{
    try
    {
        await ProcessAsync();
    }
    catch (Exception ex)
    {
        // Handle or log - don't let it crash the app
        MessageBox.Show($"Error: {ex.Message}");
    }
}

Sync-over-async: blocking on async code

Sync-over-async means calling synchronous blocking methods on async operations.

The methods to avoid

Blocking Method Problem
task.Wait() Blocks thread, wraps exceptions in AggregateException
task.Result Blocks thread, wraps exceptions in AggregateException
task.GetAwaiter().GetResult() Blocks thread, preserves original exception

ASP.NET Core specific problems

// BAD: Sync-over-async in ASP.NET Core
[HttpGet]
public IActionResult GetBad()
{
    // Blocks a thread pool thread
    var json = new StreamReader(Request.Body).ReadToEnd();
    return Ok(json);
}

// GOOD: Fully async
[HttpGet]
public async Task<IActionResult> GetGood()
{
    var json = await new StreamReader(Request.Body).ReadToEndAsync();
    return Ok(json);
}

HttpContext is not thread-safe

When using Task.WhenAll in ASP.NET Core, do not access HttpContext from parallel tasks:

// BAD: HttpContext accessed from multiple threads
[HttpGet]
public async Task<IActionResult> Search(string query)
{
    var task1 = SearchAsync(SearchEngine.Google, query);
    var task2 = SearchAsync(SearchEngine.Bing, query);
    await Task.WhenAll(task1, task2);
    return Ok(new { Google = await task1, Bing = await task2 });
}

private async Task<string> SearchAsync(SearchEngine engine, string query)
{
    // UNSAFE: HttpContext accessed from multiple threads
    _logger.LogInformation("Search from {Path}", HttpContext.Request.Path);
    return await _searchService.SearchAsync(engine, query);
}
// GOOD: Copy context values before parallel execution
[HttpGet]
public async Task<IActionResult> Search(string query)
{
    var path = HttpContext.Request.Path; // Copy once
    var task1 = SearchAsync(SearchEngine.Google, query, path);
    var task2 = SearchAsync(SearchEngine.Bing, query, path);
    await Task.WhenAll(task1, task2);
    return Ok(new { Google = await task1, Bing = await task2 });
}

private async Task<string> SearchAsync(SearchEngine engine, string query, string path)
{
    _logger.LogInformation("Search from {Path}", path);
    return await _searchService.SearchAsync(engine, query);
}

Background tasks and scoped services

Firing off async work without awaiting it causes lifetime bugs:

// BAD: Scoped DbContext used in background task
[HttpGet]
public IActionResult FireAndForget([FromServices] AppDbContext context)
{
    _ = Task.Run(async () =>
    {
        await Task.Delay(1000);
        context.Logs.Add(new LogEntry()); // ObjectDisposedException
        await context.SaveChangesAsync();
    });
    return Accepted();
}

The request ends, the scope is disposed, and the DbContext is disposed. When the background task tries to use it, it throws ObjectDisposedException.

The fix

Create a new scope for background work:

// GOOD: Create scope for background work
[HttpGet]
public IActionResult FireAndForget([FromServices] IServiceScopeFactory scopeFactory)
{
    _ = Task.Run(async () =>
    {
        await Task.Delay(1000);

        await using var scope = scopeFactory.CreateAsyncScope();
        var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        context.Logs.Add(new LogEntry());
        await context.SaveChangesAsync();
    });
    return Accepted();
}

Better: use a proper background service or queue.

Detection strategies

Async analyzers (build time)

Add the Visual Studio Threading Analyzers to catch issues at build time:

<ItemGroup>
  <PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.*" />
</ItemGroup>

Key diagnostics:

  • VSTHRD002: Avoid problematic synchronous waits
  • VSTHRD100: Avoid async void methods
  • VSTHRD101: Avoid unsupported async delegates
  • VSTHRD103: Call async methods when in an async method

Runtime blocking detection

Ben.BlockingDetector catches blocking calls at runtime during actual requests:

// Add early in pipeline - Program.cs
app.UseBlockingDetection();
<PackageReference Include="Ben.BlockingDetector" Version="0.0.4" />

This detects Task.Result, Task.Wait(), lock, and semaphore waits that actually block. It logs warnings with stack traces. Useful for catching issues in third-party libraries or complex code paths that static analysis misses.

Limitation: only catches blocking that actually blocks (not pre-completed tasks or Thread.Sleep).

Code review patterns

Search for these patterns in pull requests:

# Find sync-over-async
grep -rn "\.Result" --include="*.cs"
grep -rn "\.Wait()" --include="*.cs"
grep -rn "GetAwaiter().GetResult()" --include="*.cs"

# Find async void
grep -rn "async void" --include="*.cs"

Runtime detection

Thread pool starvation from sync-over-async manifests as:

  • Increasing request latency under load
  • Thread pool thread count at maximum
  • Requests timing out

Monitor thread pool metrics:

ThreadPool.GetAvailableThreads(out int workerThreads, out int completionPortThreads);
ThreadPool.GetMaxThreads(out int maxWorkerThreads, out int maxCompletionPortThreads);

if (workerThreads < maxWorkerThreads * 0.1)
{
    _logger.LogWarning("Thread pool near exhaustion: {Available}/{Max}",
        workerThreads, maxWorkerThreads);
}

Copy/paste artifact: async best practices

// Async best practices reference

// 1. Return Task, not void
public async Task ProcessAsync() { }  // GOOD
public async void ProcessAsync() { }  // BAD (except event handlers)

// 2. Await all the way
public async Task<string> GetAsync()
{
    return await FetchAsync();  // GOOD
}

public string Get()
{
    return FetchAsync().Result;  // BAD - blocks thread
}

// 3. ConfigureAwait in libraries
public async Task<string> LibraryMethodAsync()
{
    return await FetchAsync().ConfigureAwait(false);  // GOOD for libraries
}

// 4. Use async versions of APIs
await stream.ReadAsync(buffer);      // GOOD
stream.Read(buffer);                  // BAD in async context

await Task.Delay(1000);              // GOOD
Thread.Sleep(1000);                  // BAD in async context

await Task.WhenAll(tasks);           // GOOD
Task.WaitAll(tasks);                 // BAD - blocks thread

// 5. Background work with scoped services
_ = Task.Run(async () =>
{
    await using var scope = scopeFactory.CreateAsyncScope();
    var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    // Use db
});

Copy/paste artifact: async code review checklist

Async Code Review Checklist

1. Sync-over-async
   - [ ] No .Result on Task
   - [ ] No .Wait() on Task
   - [ ] No .GetAwaiter().GetResult() without justification

2. Async void
   - [ ] No async void methods (except event handlers)
   - [ ] Event handlers wrap body in try-catch

3. ConfigureAwait
   - [ ] Library code uses ConfigureAwait(false)
   - [ ] Application code reviewed for context requirements

4. HttpContext safety
   - [ ] HttpContext not accessed from parallel tasks
   - [ ] Context values copied before Task.WhenAll

5. Background work
   - [ ] Background tasks create new service scope
   - [ ] Scoped services not captured by fire-and-forget

6. API usage
   - [ ] Async API versions used (ReadAsync, WriteAsync)
   - [ ] Task.Delay instead of Thread.Sleep
   - [ ] Task.WhenAll instead of Task.WaitAll

Common failure modes

  1. Classic deadlock from .Result/.Wait() in GUI or legacy ASP.NET context.
  2. Thread pool starvation from sync-over-async in ASP.NET Core under load.
  3. Silent failures from async void exceptions crashing the process.
  4. ObjectDisposedException from scoped services used in background tasks.
  5. Race conditions from HttpContext accessed from parallel tasks.

Checklist

  • No .Result, .Wait(), or GetAwaiter().GetResult() without explicit justification.
  • No async void except in event handlers.
  • Library code uses ConfigureAwait(false).
  • Background tasks create new service scopes.
  • HttpContext values copied before parallel execution.
  • Async analyzers enabled in build.

FAQ

Does ASP.NET Core have a SynchronizationContext?

No. ASP.NET Core deliberately does not install a SynchronizationContext. This eliminates the classic deadlock scenario but does not eliminate thread pool starvation from sync-over-async.

When is .Result or .Wait() acceptable?

In console application Main methods before C# 7.1 (when async Main was not supported). In ASP.NET Core, almost never. If you must bridge sync and async, wrap in Task.Run to avoid capturing any potential context.

Should I use ConfigureAwait(false) in ASP.NET Core?

For application code in ASP.NET Core request handlers, it has no effect. For library code or code that might run in other contexts, yes.

How do I catch exceptions from async void event handlers?

Wrap the method body in try-catch. Exceptions from async void methods cannot be caught outside the method.

What is the difference between .Result and .GetAwaiter().GetResult()?

Both block the thread. .Result wraps exceptions in AggregateException. .GetAwaiter().GetResult() preserves the original exception type. Neither should be used in async code paths.

Can ValueTask be awaited multiple times?

No. ValueTask should only be awaited once. If you need to await the same result multiple times, use Task or call .AsTask() on the ValueTask.

Why is async void bad in C#?

Three reasons: (1) exceptions crash the process instead of being catchable, (2) callers cannot await completion, and (3) unit testing is difficult. The only acceptable use is event handlers, and even those should wrap the body in try-catch.

What to do next

Add the Visual Studio Threading Analyzers to your project today. Then search your codebase for .Result, .Wait(), and async void. Each occurrence deserves review.

For more on building production-quality ASP.NET Core applications, read Dependency Injection Anti-Patterns in ASP.NET Core.

If you want help reviewing async patterns in your codebase, reach out via Contact.

References

Author notes

Decisions:

  • Flag all .Result/.Wait() as suspect. Rationale: even in ASP.NET Core, they cause thread pool starvation under load.
  • Recommend ConfigureAwait(false) for libraries only. Rationale: ASP.NET Core has no context, but library code may be called from contexts that do.
  • Require try-catch in async void event handlers. Rationale: unhandled exceptions crash the process.

Observations:

  • Deadlocks from sync-over-async often appear only in production under load, not in development.
  • Thread pool starvation manifests as slow requests and timeouts, not immediate errors.
  • Async void bugs surface as mystery crashes in production logs.