//JorgenHoc
← All articles
Async C#By Jorge CalderónUpdated 9 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.

This is not a hand-waved scenario: the hang is reproduced deterministically in samples/async-deadlocks-csharp — a console app that installs a ~40-line UI-like SynchronizationContext (the same one-thread rule WinForms and WPF impose), triggers the deadlock, observes it with a timeout instead of hanging, and then asserts that every escape hatch in this article actually escapes it.

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

The sample proves that mutual wait is really what's happening, not just a plausible story: when its timed-out Wait finally releases the blocked thread, the queued continuation runs and the task completes on its own, immediately. The task was never stuck — it was only waiting for the thread that was waiting for it.

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

The sample runs this table as four assertions — same async method, same blocking call, one ingredient toggled at a time:

DemoContext?Blocking?Measured outcome
Plain console threadnoyescompletes
UI-like threadyesyesdeadlock (observed via 2s timeout)
UI-like thread + ConfigureAwait(false)yesyescompletes
UI-like thread + Task.Runyesyescompletes
Console output of the deadlock sample: the plain console thread has no SynchronizationContext and blocking completes fine; the UI-like thread deadlocks and Wait times out after two seconds, then the task completes immediately once the thread is released; ConfigureAwait(false) and Task.Run both complete on the same UI-like thread. All checks passed.
The sample run: same async method and same blocking call in all four demos — only the ingredients change, and only the context-plus-blocking combination deadlocks.

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)) returns false after the timeout instead of hanging forever, letting you throw and make 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");
}

This timeout pattern is exactly how the sample observes its deliberate deadlock without ever hanging — and it has a diagnostic bonus: after the timed-out Wait releases the thread, a deadlocked-on-context task completes immediately, which distinguishes this deadlock from a genuinely stuck dependency.

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.

Further reading

About the author

Jorge Calderón

Software engineer with over a decade building and operating .NET applications in production — EF Core data layers, async-heavy services, and Azure and container deployments. Every benchmark and sample project in these guides is published in a public GitHub repository so you can rerun it yourself.

GitHub profileLinkedIn ↗Benchmarks & sample code

Related articles