//JorgenHoc
← All articles
Async C#7 min read

How to Avoid Async Deadlocks in C#

Understand why async deadlocks happen in C# with .Result and .Wait(), how SynchronizationContext causes them, and the patterns that prevent them entirely.

#csharp#async#dotnet

Async deadlocks are one of the most confusing bugs in C#. The app hangs. No exception. No error. The request just never responds. Understanding why they happen — and more importantly, why they don't happen in certain contexts — is essential knowledge for every .NET developer.

The Deadlock Scenario

This code deadlocks in classic ASP.NET and in UI apps (WinForms, WPF):

// In a classic ASP.NET controller or WinForms event handler
public ActionResult GetData()
{
    // .Result blocks the current thread and waits for the task
    var data = FetchDataAsync().Result;
    return View(data);
}
 
private async Task<string> FetchDataAsync()
{
    // This await captures the current SynchronizationContext
    var result = await httpClient.GetStringAsync("https://api.example.com/data");
    return result;
}

The app hangs indefinitely. Here's exactly why.

Why the Deadlock Happens

The deadlock requires two conditions to occur simultaneously:

  1. There is a SynchronizationContext on the calling thread
  2. The calling thread is blocked with .Result or .Wait()

The sequence:

  1. GetData() runs on the ASP.NET request thread (which has a SynchronizationContext)
  2. FetchDataAsync() starts and hits await httpClient.GetStringAsync(...)
  3. await captures the current SynchronizationContext — it notes "when this completes, resume on the ASP.NET request context"
  4. Control returns to GetData(), which calls .Resultthis blocks the ASP.NET request thread
  5. The HTTP call completes and tries to resume FetchDataAsync() on the ASP.NET request context
  6. But the request context is occupied by the blocked .Result call
  7. Deadlock. The task waits for the thread; the thread waits for the task.
Thread (blocked on .Result)
  └── waiting for FetchDataAsync to complete
        └── waiting to resume on THIS thread
              └── but this thread is blocked
                    └── DEADLOCK

Why ASP.NET Core Doesn't Deadlock

ASP.NET Core does not have a SynchronizationContext. This is a deliberate design decision. When await completes in ASP.NET Core, it resumes on any available thread pool thread — it doesn't try to get back to a specific thread.

// This doesn't deadlock in ASP.NET Core (but it's still bad practice!)
[HttpGet]
public IActionResult GetData()
{
    var data = FetchDataAsync().Result; // No deadlock in Core — but still wastes a thread
    return Ok(data);
}
⚠️

Even though .Result doesn't deadlock in ASP.NET Core, it still blocks a thread pool thread while waiting. Under load, this starves the thread pool. Never use .Result or .Wait() in ASP.NET Core request handlers.

The SynchronizationContext Problem

SynchronizationContext is an abstraction that controls where code runs after an async operation completes. Different frameworks have different contexts:

ContextWhere code resumes after await
ASP.NET ClassicOriginal request thread
WinFormsUI thread
WPFUI thread (Dispatcher)
ASP.NET CoreAny thread pool thread (no SynchronizationContext)
Console appAny thread pool thread (no SynchronizationContext)
xUnit testsCustom context (can deadlock!)

ConfigureAwait(false) as the Solution

ConfigureAwait(false) tells await not to capture the SynchronizationContext:

private async Task<string> FetchDataAsync()
{
    // ConfigureAwait(false) — resume on any thread pool thread
    var result = await httpClient.GetStringAsync("https://api.example.com/data")
                                 .ConfigureAwait(false);
    return result;
}

Now the deadlock chain is broken. When the HTTP call completes, it resumes on a thread pool thread (not the captured request context), so the blocked .Result call is no longer in the way.

ConfigureAwait(false) in Library Code

Library code should always use ConfigureAwait(false). Libraries don't control the calling environment — your library might be called from WinForms, classic ASP.NET, or anywhere with a SynchronizationContext.

// Good library code — safe to call from anywhere
public static async Task<ApiResponse> GetApiDataAsync(string url)
{
    var response = await httpClient.GetAsync(url).ConfigureAwait(false);
    response.EnsureSuccessStatusCode();
    var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
    return JsonSerializer.Deserialize<ApiResponse>(content)!;
}

The Real Fix: Go Async All the Way

ConfigureAwait(false) is a band-aid. The real solution is to never block on async code:

// WRONG
public ActionResult GetData()
{
    var data = FetchDataAsync().Result; // Blocking
    return View(data);
}
 
// CORRECT — async all the way down
public async Task<ActionResult> GetDataAsync()
{
    var data = await FetchDataAsync(); // Non-blocking
    return View(data);
}

This requires making the entire call chain async. Every method that calls an async method should itself be async. This is the "async all the way" principle.

Common Deadlock Patterns

Pattern 1: .Result in a constructor

// DEADLOCK — constructors can't be async
public class DataCache
{
    private readonly List<Item> _items;
 
    public DataCache(IDataService service)
    {
        _items = service.GetItemsAsync().Result; // Deadlocks in sync context
    }
}
 
// FIX — use a factory method
public class DataCache
{
    private readonly List<Item> _items;
    private DataCache(List<Item> items) => _items = items;
 
    public static async Task<DataCache> CreateAsync(IDataService service)
    {
        var items = await service.GetItemsAsync();
        return new DataCache(items);
    }
}

Pattern 2: .Wait() in a property getter

// DEADLOCK
public string CurrentUserName
{
    get => GetCurrentUserAsync().Result; // Deadlocks in UI apps
}
 
// FIX — don't make async calls in property getters
// Use async methods instead, or cache the value
public async Task<string> GetCurrentUserNameAsync()
{
    return await _userService.GetCurrentUserAsync();
}

Pattern 3: async void with .Wait()

// PROBLEMATIC
private async void LoadData()
{
    var data = await FetchAsync(); // Fine
    Display(data);
}
 
// Calling it and waiting
void OnButtonClick()
{
    LoadData();
    // Can't await async void — no way to know when it's done
    UpdateUI(); // Runs before LoadData completes
}

Pattern 4: Task.Run to work around deadlocks

// Workaround (not ideal, but avoids deadlock)
public string GetData()
{
    // Task.Run runs on a thread pool thread — no SynchronizationContext
    var data = Task.Run(() => FetchDataAsync()).Result;
    return data;
}

This works because Task.Run executes on a thread pool thread that has no SynchronizationContext. The await inside FetchDataAsync has nothing to capture, so the completion posts to the thread pool instead of trying to return to the blocked thread.

⚠️

Task.Run as a deadlock workaround is a code smell. It works but uses extra threads unnecessarily. Fix the root cause by making the caller async.

When Deadlocks Can't Happen

Deadlocks only occur when BOTH conditions are true:

  1. A SynchronizationContext is present
  2. The thread with that context is blocked

No deadlock possible in:

  • ASP.NET Core (no SynchronizationContext)
  • Console applications (no SynchronizationContext)
  • Thread pool threads (no SynchronizationContext)
  • Code that uses ConfigureAwait(false) at every await point

Deadlock possible in:

  • Classic ASP.NET (.aspx handlers, Web API 2)
  • WinForms event handlers
  • WPF event handlers
  • Any context where SynchronizationContext.Current is non-null AND you block the thread

Detecting Deadlocks

When an app hangs with no exception, check:

  1. Thread dump — look for threads waiting on WaitOne, WaitAll, or Monitor.Wait while other threads are queued waiting to post to them
  2. Visual Studio Parallel Stacks — shows thread dependencies, making deadlocks visible
  3. Add a timeout.Wait(TimeSpan.FromSeconds(5)) throws TimeoutException instead of hanging forever, making the bug visible
// Diagnostic version — throws after timeout instead of hanging
if (!FetchDataAsync().Wait(TimeSpan.FromSeconds(5)))
{
    throw new TimeoutException("FetchDataAsync timed out — possible deadlock");
}

Summary

ScenarioWhat to Do
Library codeAlways use ConfigureAwait(false)
ASP.NET CoreGo async all the way (.Result won't deadlock but wastes threads)
Classic ASP.NETGo async all the way OR use ConfigureAwait(false) throughout
WinForms/WPFGo async all the way; don't block the UI thread
ConstructorsUse static factory methods instead
Property gettersDon't make async calls from property getters

The golden rule: never block on async code. If you find yourself reaching for .Result, .Wait(), or .GetAwaiter().GetResult(), make the caller async instead.