Fix: C# async deadlock — Task.Result and .Wait() hanging forever
Part of: C# and .NET Errors
Quick Answer
How to fix the C# async/await deadlock caused by Task.Result and .Wait() blocking the synchronization context in ASP.NET, WPF, WinForms, and library code.
The Hang You Cannot Step Through
Personally, the async deadlock is the bug I have seen senior .NET developers chase the longest, because the symptoms point at everything except the cause. The application freezes with no exception. The debugger shows threads waiting but never tells you which thread holds the resource the others want. I once spent two days on this in a WPF migration before someone pointed out the single .Result call that was the entire issue.
Your C# application hangs indefinitely. No exception is thrown, no crash report is generated, the application simply freezes. In a WPF or WinForms app, the UI becomes unresponsive. In ASP.NET, the request never completes and eventually times out.
The code typically looks like this:
// This hangs forever
var result = GetDataAsync().Result;// This also hangs forever
GetDataAsync().Wait();// ASP.NET controller — request never returns
public ActionResult Index()
{
var data = _service.GetDataAsync().Result;
return View(data);
}// WPF button handler — UI freezes
private void Button_Click(object sender, RoutedEventArgs e)
{
var result = LoadDataAsync().Result;
TextBlock.Text = result;
}You are calling .Result or .Wait() on an async method, and the application locks up completely. The debugger shows threads waiting, but nothing moves forward. This is a classic async/await deadlock.
Quick Reference Before You Dive In
If you arrived here from Google with a frozen .NET application, the five facts that resolve roughly 90 percent of the cases I have triaged:
- The deadlock is caused by
.Resultor.Wait()on async code from a thread with a synchronization context. That means classic ASP.NET request threads, WPF UI threads, and WinForms UI threads. The blocking call holds the only thread the continuation needs. - ASP.NET Core has no synchronization context. The classic
.Resultdeadlock does not happen there. If you migrated to ASP.NET Core and still see hangs, look at thread pool starvation, lock contention, or a logical deadlock, not the sync context. - The durable fix is “async all the way.” Never block on async code. Make every method in the chain
async, returnTaskorTask<T>, andawaitinstead of.Result. This is Fix 1 below and it solves the problem permanently. ConfigureAwait(false)only helps if applied to every await in the called method. Adding it to the top-level call alone does nothing because the inner awaits still capture the context. This is the single most common misunderstanding I see in code review.- The canonical reference is Stephen Toub’s ConfigureAwait FAQ on the .NET blog. Worth reading once even if you think you already understand the topic; the post anticipates and answers every nuanced question.
The rest of this article walks through each of those in detail, plus the failure modes most other guides skip.
How the Synchronization Context Locks You
The deadlock happens because of how the synchronization context works in .NET.
When you await something in ASP.NET (pre-Core), WPF, or WinForms, the framework captures the current synchronization context. After the awaited task completes, the continuation (the code after await) is posted back to that same context: the UI thread in desktop apps, or the request thread in ASP.NET.
Here is the sequence that causes the deadlock:
- You call
GetDataAsync().Resulton the main thread (UI thread or ASP.NET request thread). .Resultblocks the current thread, waiting for the task to finish.- Inside
GetDataAsync, anawaitis hit. The async method yields and schedules its continuation to run on the captured synchronization context, which is the same thread you just blocked in step 2. - The continuation cannot run because the thread is blocked by
.Result. .Resultcannot return because the continuation never runs.- Deadlock. Both sides are waiting on each other forever.
public async Task<string> GetDataAsync()
{
// This await captures the synchronization context
var response = await httpClient.GetAsync("https://api.example.com/data");
// This continuation needs to run on the original thread
// But that thread is blocked by .Result
return await response.Content.ReadAsStringAsync();
}The same deadlock does not happen in console applications because they use the default thread pool synchronization context, which doesn’t force continuations back to a specific thread. It also does not happen in ASP.NET Core by default, because ASP.NET Core does not have a synchronization context. However, you can still run into related issues if your code depends on libraries that capture the context.
This deadlock pattern is one of the most common traps in C# async programming. It is similar in concept to the goroutine deadlock in Go, where concurrent operations block each other indefinitely.
.NET async/await Version History
The async story in .NET has evolved significantly since C# 5.0. Knowing which platform and version you target determines which fixes are available without an upgrade and which ones the runtime now handles for you.
| Version | Released | Async-related change |
|---|---|---|
| C# 5.0 / .NET 4.5 | Aug 2012 | async / await keywords introduced. Synchronization context capture is the default behavior. |
| .NET Core 1.0 | Jun 2016 | ASP.NET Core ships without a SynchronizationContext for request threads. The classic .Result deadlock does not occur in new ASP.NET Core apps. |
| C# 7.1 / .NET Core 2.0 | Aug 2017 | async Main entry point. Removes the most common reason to call .GetAwaiter().GetResult() in Main. |
| .NET Standard 2.1 / .NET Core 3.0 | Sep 2019 | IAsyncDisposable, IAsyncEnumerable, await foreach, await using. Async lifecycle becomes first-class throughout the BCL. |
| .NET 6 | Nov 2021 | Task.WaitAsync(TimeSpan) lets you bound a hung wait without external cancellation tokens. Improved async exception display in tracebacks. |
| .NET 8 | Nov 2023 | ConfigureAwait(ConfigureAwaitOptions) enum overload (None, ContinueOnCapturedContext, SuppressThrowing, ForceYielding). More granular control than the old boolean overload. |
| .NET 9 | Nov 2024 | Further async stack-trace cleanup and small allocation reductions. No behavior changes that affect this deadlock pattern. |
The split that matters most for this specific bug is .NET Framework / classic ASP.NET / WPF / WinForms (has SynchronizationContext) versus ASP.NET Core and Console apps (no SynchronizationContext). The classic .Result deadlock is a property of the former. Migrating to ASP.NET Core eliminates this entire class of bug, which is the strongest single argument for the migration if your codebase still calls .Result in places you cannot easily refactor.
Diagnostic Timeline
When an ASP.NET request or WPF window hangs and you reach for the debugger, work the timeline below before sprinkling ConfigureAwait(false) everywhere. Most teams “fix” the symptom and ship a second deadlock a week later because the root cause was sync-over-async, not the synchronization context per se.
Minute 0: Confirm the hang is a deadlock, not slow I/O. Open the Threads window in Visual Studio (or run dotnet-counters monitor against the process). A deadlock shows zero CPU on the request thread but a non-completing task. Slow I/O shows the request thread idle and the network/disk counters busy. Treating slow I/O as a deadlock leads to wrong fixes.
Minute 1: Locate every .Result, .Wait(), and .GetAwaiter().GetResult() on the call stack. These are the only call sites that can deadlock against a captured context:
# From the project root
grep -rn --include="*.cs" -E "\.Result\b|\.Wait\(\)|GetAwaiter\(\)\.GetResult\(\)" .The deadlock lives at whichever blocking call is on the captured-context thread (UI thread or classic ASP.NET request thread). Everything else is noise.
Minute 3: Capture a hang dump. Do not attach a debugger blindly. Capture a dump first so you can compare states. The dotnet-dump tool is the modern, cross-platform replacement for the older Windows-specific procdump path:
dotnet-dump collect -p <PID> -o hang.dmpThen analyze with SOS:
dotnet-dump analyze hang.dmp
> threads
> syncblk
> clrstack -all!syncblk lists every monitor lock and which thread holds it. The thread you want is the one with OwningThread set and MonitorHeld > 0. Its stack will show the offending .Result or .Wait().
Minute 5: Rule out the non-deadlock causes. Three patterns look like deadlocks but are not:
- A
TaskCompletionSource<T>withoutTaskCreationOptions.RunContinuationsAsynchronously— synchronous continuations chain on the completing thread and can re-enter a held lock. - Thread-pool starvation — too many
Task.Runcalls saturating the worker pool.dotnet-counters monitor System.RuntimeshowsThreadPool Queue Lengthclimbing without bound. - An
awaitinside alockblock — the continuation may resume on a different thread that does not hold the lock, causing apparent deadlock the next time the lock is acquired.
Minute 7: Test the synchronization context hypothesis. Replace the suspected .Result with await and rerun the failing path. If the hang clears, you confirmed a classic sync-over-async deadlock and Fix 1 below is the durable answer.
Minute 10: Only then start adding ConfigureAwait(false). Spraying it everywhere without understanding which await is captured by which context turns a deadlock into a different bug, with UI code accidentally running off the UI thread.
When to Use Which Fix
The next eight sections cover the eight fixes in detail. Before diving in, the table below maps your situation to the specific fix I would reach for first. It is the cheat sheet I wish I had when learning this.
| Your situation | Recommended fix | Why this one |
|---|---|---|
| Application code where the caller can be made async | Fix 1: async all the way | Durable answer; removes the deadlock at the root rather than working around it |
| Library or NuGet package consumed by unknown contexts | Fix 2: ConfigureAwait(false) on every await | Decouples the library from whatever sync context the caller runs under |
Classic ASP.NET controller blocking on .Result | Fix 3: change the action signature to async Task<ActionResult> | The AspNetSynchronizationContext is the most common single deadlock source |
| WPF or WinForms event handler that freezes the UI | Fix 4: async void event handler with try / finally | The one legitimate use of async void; keeps the UI responsive without changing the event signature |
| Legacy synchronous code that cannot be refactored right now | Fix 5: Task.Run wrapper, but watch for thread pool starvation | Breaks the context capture but consumes a thread per call; avoid under load |
Non-event-handler that someone wrote as async void | Fix 6: convert to async Task | async void breaks composition and exception handling everywhere it appears outside event handlers |
| Constructor or property getter that needs async setup | Fix 7: factory method, AsyncLazy<T>, or explicit InitializeAsync | Constructors and getters cannot be async; pick the pattern that fits your DI setup |
Main method in pre-C# 7.1, or an interface that cannot be made async | Fix 8: GetAwaiter().GetResult(), with a comment explaining why | Last resort; the only blocking call style that should appear in modern code, and always with a TODO |
If multiple rows look like they apply, pick the highest one. The fixes are roughly ordered from “permanent solution” to “workaround for a constraint you cannot remove right now.”
Fix 1: Use async/await All the Way
The best fix is to never block on async code. Make your entire call chain async, from the entry point down to the lowest-level async call.
Before (deadlock):
public ActionResult Index()
{
var data = _service.GetDataAsync().Result; // Blocks — deadlock
return View(data);
}After (correct):
public async Task<ActionResult> Index()
{
var data = await _service.GetDataAsync(); // No blocking
return View(data);
}In WPF and WinForms, make event handlers async void (this is the one valid use of async void):
private async void Button_Click(object sender, RoutedEventArgs e)
{
var result = await LoadDataAsync();
TextBlock.Text = result;
}The rule is simple: if you call an async method, you should await it. Let async propagate up through your call stack. Every method that calls an async method should itself be async, returning Task or Task<T>.
// All the way up the chain
public async Task<string> GetUserNameAsync()
{
var user = await _repository.GetUserAsync(userId);
return user.Name;
}
public async Task<ActionResult> Profile()
{
var name = await GetUserNameAsync();
return View(name);
}This is sometimes called “async all the way” and it is the single most important rule for avoiding async deadlocks in C#.
Fix 2: Use ConfigureAwait(false) in Library Code
If you are writing library code that does not need to return to the original synchronization context, add ConfigureAwait(false) to every await:
public async Task<string> GetDataAsync()
{
var response = await httpClient.GetAsync(url).ConfigureAwait(false);
var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
return content;
}ConfigureAwait(false) tells the awaiter: “I don’t need to resume on the captured synchronization context. Any thread pool thread is fine.” This breaks the deadlock because the continuation no longer competes for the blocked thread.
When to use it:
- In library code, NuGet packages, and shared utility methods: always.
- In application-level code (controllers, UI handlers): generally not needed if you follow Fix 1. But it doesn’t hurt.
When NOT to use it:
- After
ConfigureAwait(false), you cannot access UI elements (WPF/WinForms) orHttpContext(ASP.NET) because you are no longer on the original context. If you need to update UI elements after an await, do not useConfigureAwait(false)on that specific await.
private async void Button_Click(object sender, RoutedEventArgs e)
{
// ConfigureAwait(false) — runs continuation on thread pool
var data = await LoadDataAsync().ConfigureAwait(false);
// BUG: This will throw — you're no longer on the UI thread
TextBlock.Text = data;
}A nuance I have explained more times than I can count in code review: in .NET Core and .NET 5+, ASP.NET Core does not have a synchronization context, so ConfigureAwait(false) does not prevent any deadlock there (there is no context to deadlock against). I still require it in library code that my teams ship as NuGet packages, because the library may be consumed by a WPF app, a WinForms tool, or a legacy ASP.NET service that does have a context. The cost of writing it is one method call. The cost of NOT writing it shows up only in the downstream consumer’s bug report.
Fix 3: Fix ASP.NET Synchronization Context Deadlock
In classic ASP.NET (not ASP.NET Core), the AspNetSynchronizationContext allows only one thread in the request context at a time. This is the most common environment where the .Result deadlock occurs.
Option A: Make the controller action async (recommended):
// Before
public ActionResult GetUsers()
{
var users = _userService.GetUsersAsync().Result; // Deadlock
return Json(users);
}
// After
public async Task<ActionResult> GetUsers()
{
var users = await _userService.GetUsersAsync();
return Json(users);
}Option B: If you absolutely cannot change the method signature, ensure the library code uses ConfigureAwait(false) throughout:
public async Task<List<User>> GetUsersAsync()
{
var response = await _httpClient.GetAsync("/api/users").ConfigureAwait(false);
var json = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
return JsonConvert.DeserializeObject<List<User>>(json);
}With ConfigureAwait(false) on every await inside GetUsersAsync, calling .Result from the controller will not deadlock because the continuations no longer try to return to the ASP.NET request thread.
Option C: Migrate to ASP.NET Core. ASP.NET Core removed the synchronization context entirely, which eliminates this entire class of deadlock. If you are starting a new project or have the option to migrate, this is the long-term fix.
If this issue is causing cascading errors like TaskCanceledException from request timeouts, fixing the deadlock will resolve those as well.
Fix 4: Fix WPF/WinForms UI Thread Deadlock
In WPF and WinForms, the UI thread has a DispatcherSynchronizationContext (WPF) or WindowsFormsSynchronizationContext (WinForms). When you block this thread with .Result or .Wait(), you get both a deadlock and a frozen UI.
Fix the event handler:
// Before — freezes UI
private void Button_Click(object sender, RoutedEventArgs e)
{
var result = FetchDataAsync().Result;
label.Content = result;
}
// After — responsive UI
private async void Button_Click(object sender, RoutedEventArgs e)
{
button.IsEnabled = false;
try
{
var result = await FetchDataAsync();
label.Content = result;
}
catch (Exception ex)
{
label.Content = $"Error: {ex.Message}";
}
finally
{
button.IsEnabled = true;
}
}Notice the async void — event handlers are the only place where async void is acceptable. For everything else, always return Task or Task<T>.
If you need to run async code from a synchronous context in WPF (e.g., in a constructor or a method that cannot be made async), use Dispatcher.InvokeAsync:
public MainWindow()
{
InitializeComponent();
Dispatcher.InvokeAsync(async () =>
{
var data = await LoadInitialDataAsync();
DataGrid.ItemsSource = data;
});
}This queues the async work on the dispatcher without blocking the UI thread.
Fix 5: Use Task.Run to Offload Blocking Calls
When you are stuck with synchronous code that must call an async method, Task.Run can break the deadlock by moving the async call to a thread pool thread that has no synchronization context:
// Wrapping in Task.Run avoids the deadlock
var result = Task.Run(() => GetDataAsync()).Result;Or more explicitly:
var result = Task.Run(async () =>
{
return await GetDataAsync();
}).GetAwaiter().GetResult();This works because Task.Run executes the delegate on a thread pool thread, which uses the default synchronization context. The await inside GetDataAsync does not try to marshal back to any special thread, so no deadlock occurs.
Limitations:
- This creates an extra thread pool thread, which adds overhead.
- In ASP.NET (classic), this consumes an additional thread from the thread pool, which can hurt scalability under heavy load. Avoid this pattern in web applications if possible.
- This is a workaround, not a fix. The correct solution is Fix 1: making the call chain async all the way.
A subtle trap I have shipped to production once and caught in review at least a dozen times: do not confuse Task.Run(() => GetDataAsync()) with Task.Run(() => GetDataAsync().Result). The second still blocks a thread pool thread on .Result, just from a different stack frame, and the deadlock surfaces under load when the thread pool starves. The first correctly awaits inside Task.Run. The two lines look nearly identical in code review; the bug is invisible until the staging environment hits real concurrency.
Fix 6: Fix async void Methods
async void methods are fire-and-forget. They cannot be awaited, and their exceptions crash the process instead of being captured in a Task. They also interact poorly with synchronization contexts and can cause subtle deadlocks.
Problem:
// async void — cannot be awaited
public async void InitializeData()
{
var data = await LoadDataAsync();
_cache = data;
}
// Caller has no way to wait for completion
public void Configure()
{
InitializeData(); // Starts but doesn't wait
// _cache might still be null here
UseCache(); // NullReferenceException or race condition
}If the caller tries to work around this by adding a delay or blocking mechanism, deadlocks can result.
Fix: Return Task instead of void:
public async Task InitializeDataAsync()
{
var data = await LoadDataAsync();
_cache = data;
}
public async Task ConfigureAsync()
{
await InitializeDataAsync();
UseCache(); // _cache is guaranteed to be populated
}The only valid uses of async void are:
- Event handlers (e.g.,
Button_Click), because the event delegate signature requiresvoid. - Top-level entry points where you explicitly want fire-and-forget behavior and have proper error handling.
If an async void method is causing issues that resemble a NullReferenceException — where an object is unexpectedly null — the root cause might be that the async initialization did not complete before the object was used.
Fix 7: Fix Deadlock in Constructors and Property Getters
Constructors cannot be async. Property getters also cannot be async. This tempts developers into writing code like this:
public class DataService
{
public List<Item> Items { get; }
public DataService()
{
// Deadlock if called from UI thread or ASP.NET
Items = LoadItemsAsync().Result;
}
}Fix A: Use a factory method:
public class DataService
{
public List<Item> Items { get; private set; }
private DataService() { }
public static async Task<DataService> CreateAsync()
{
var service = new DataService();
service.Items = await LoadItemsAsync();
return service;
}
}
// Usage
var service = await DataService.CreateAsync();Fix B: Use lazy async initialization:
public class DataService
{
private readonly AsyncLazy<List<Item>> _items;
public DataService()
{
_items = new AsyncLazy<List<Item>>(() => LoadItemsAsync());
}
public async Task<List<Item>> GetItemsAsync()
{
return await _items;
}
}You can implement AsyncLazy<T> using Lazy<Task<T>>:
public class AsyncLazy<T> : Lazy<Task<T>>
{
public AsyncLazy(Func<Task<T>> taskFactory)
: base(() => Task.Run(taskFactory))
{ }
public TaskAwaiter<T> GetAwaiter() => Value.GetAwaiter();
}Fix C: Use an Initialize method:
public class DataService
{
public List<Item> Items { get; private set; }
public async Task InitializeAsync()
{
Items = await LoadItemsAsync();
}
}
// In DI setup or startup
var service = new DataService();
await service.InitializeAsync();This pattern is common in dependency injection scenarios. Some DI containers support async initialization through extensions like Microsoft.Extensions.Hosting with IHostedService.
When async constructors cause type conversion errors — for instance, trying to assign a Task<List<Item>> to List<Item> — it is a sign that you are missing an await and should restructure to use one of the patterns above.
Fix 8: Use GetAwaiter().GetResult() as a Last Resort
When you absolutely must call async code synchronously — and you have exhausted all other options — use GetAwaiter().GetResult() instead of .Result or .Wait():
var result = GetDataAsync().GetAwaiter().GetResult();Why this is marginally better than .Result:
.Resultwraps exceptions in anAggregateException.GetAwaiter().GetResult()throws the original exception directly, making debugging easier.- The behavior regarding deadlocks is identical —
GetAwaiter().GetResult()blocks the thread the same way.Resultdoes. It does not prevent deadlocks by itself.
To actually avoid the deadlock with this pattern, combine it with ConfigureAwait(false) in the called method:
// The called method must use ConfigureAwait(false)
public async Task<string> GetDataAsync()
{
var response = await _client.GetAsync(url).ConfigureAwait(false);
return await response.Content.ReadAsStringAsync().ConfigureAwait(false);
}
// Then this won't deadlock (but it's still blocking a thread)
var data = GetDataAsync().GetAwaiter().GetResult();Or combine it with Task.Run:
var data = Task.Run(() => GetDataAsync()).GetAwaiter().GetResult();When you might need this:
Mainmethod in older C# versions (before C# 7.1 addedasync Main).- Interface implementations that cannot be changed to async.
- Legacy code paths where making the full chain async is not feasible right now.
Warning: This is a workaround for legacy constraints. Every use of GetAwaiter().GetResult() should have a comment explaining why the code cannot be made properly async, and ideally a TODO to fix it later. Do not treat this as a normal pattern.
Async Deadlock Cases I Have Personally Hit
If you have applied the fixes above and the application still hangs, check these less obvious causes:
Check for nested deadlocks. You may have fixed the immediate call site but a dependency deeper in the call chain is also calling .Result or .Wait(). Search your codebase for all occurrences:
// Search for all blocking calls on async code
grep -rn "\.Result" --include="*.cs" .
grep -rn "\.Wait()" --include="*.cs" .Check third-party libraries. A NuGet package might be blocking internally. Check the library’s GitHub issues for known deadlock problems. If the library is the issue, wrapping the call in Task.Run (Fix 5) is often the only option.
Check for SemaphoreSlim or lock contention. If your async code uses SemaphoreSlim.WaitAsync() or lock statements, another part of the code might be holding the lock while blocking on .Result, creating a deadlock that is not caused by the synchronization context:
private readonly SemaphoreSlim _semaphore = new(1, 1);
public async Task UpdateAsync()
{
await _semaphore.WaitAsync(); // Waits forever if another call holds the semaphore
try
{
await DoWorkAsync();
}
finally
{
_semaphore.Release();
}
}Check if you are on the right .NET version. ASP.NET Core does not have a synchronization context, so the classic .Result deadlock does not happen there. If you migrated from ASP.NET to ASP.NET Core and still see hangs, the cause is likely something else: thread pool starvation, lock contention, or an actual logical deadlock. An async hang in .NET can also indicate infinitely recursive async calls, where each await schedules another await on the same chain.
Enable async debugging in Visual Studio. Go to Debug > Windows > Tasks (or Parallel Stacks) to see which tasks are waiting and what they are waiting on. The Threads window will show which threads are blocked. This is the fastest way to diagnose which specific .Result or .Wait() call is causing the deadlock.
Check for async over sync patterns. If an async method internally calls synchronous blocking code (like Thread.Sleep instead of Task.Delay, or synchronous file I/O instead of async file I/O), it can starve the thread pool and cause apparent deadlocks under load even without the synchronization context issue:
// Bad — blocks a thread pool thread
public async Task<string> GetDataAsync()
{
Thread.Sleep(5000); // Use await Task.Delay(5000) instead
return "data";
}Replace synchronous blocking calls with their async equivalents: Task.Delay instead of Thread.Sleep, ReadAsync instead of Read, SendAsync instead of Send.
Audit your TaskCompletionSource<T> usage. A subtle deadlock source is creating a TaskCompletionSource<T> without TaskCreationOptions.RunContinuationsAsynchronously. When you call SetResult from inside a lock or event handler, every continuation chained on that task runs synchronously on the completing thread. If one of those continuations tries to acquire the same lock or post to a captured context, the process hangs:
// Wrong — continuations run synchronously on SetResult thread
var tcs = new TaskCompletionSource<int>();
// Right — continuations always scheduled on the thread pool
var tcs = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);Add RunContinuationsAsynchronously to every TaskCompletionSource<T> unless you have a specific reason not to.
Watch for HttpClient lifecycle issues masquerading as deadlocks. Creating a new HttpClient per request leaks sockets and eventually causes SendAsync to hang waiting for a free port. If your “deadlock” only appears under load and stack traces show many threads parked in HttpClient.SendAsync, you are out of ephemeral ports, not deadlocked. Use IHttpClientFactory or a single shared HttpClient with SocketsHttpHandler.PooledConnectionLifetime.
Check for lock blocks that contain await. The C# compiler does not let you await inside a lock block, but it does let you await inside Monitor.Enter/Monitor.Exit or inside Mutex.WaitOne. When the continuation resumes on a different thread, the lock is released by a thread that does not own it, and subsequent acquirers wait forever. Replace these with SemaphoreSlim.WaitAsync followed by Release in a try/finally.
Look for ConfigureAwait(false) applied only at the top. ConfigureAwait(false) only affects the await it is attached to — not subsequent awaits in the same method, and not awaits inside methods called from this one. If a library uses ConfigureAwait(false) on the outer call but the inner methods do not, the inner await still captures the context and the deadlock persists. Search for files that mix ConfigureAwait(false) and bare await — those are the suspects.
What Other Tutorials Get Wrong About Async Deadlocks
Most tutorials on this error give the right list of fixes but framed in ways that mislead. The gaps I see most often:
They recommend ConfigureAwait(false) as the primary fix. It is a workaround for library code that has to coexist with a sync context, not a fix for application code that should be “async all the way.” When tutorials lead with ConfigureAwait(false), readers spray it everywhere without understanding which awaits are captured by which context, and end up with UI handlers that accidentally run off the UI thread. Fix 1 above is the durable answer; ConfigureAwait(false) is the patch when Fix 1 is not available.
They show Task.Run as a “fix” without warning about thread pool starvation. Task.Run(() => GetDataAsync()).Result does break the deadlock, but it consumes a thread pool thread for the entire duration of the call. In a busy ASP.NET application this turns into thread pool starvation, which looks like a different deadlock and is harder to diagnose. Most tutorials never mention the trade-off.
They use ConfigureAwait(false) examples without explaining ASP.NET Core does not need it. Stephen Toub’s ConfigureAwait FAQ explicitly states that ConfigureAwait(false) in ASP.NET Core application code is unnecessary because there is no synchronization context to capture. Tutorials that show the same ConfigureAwait(false) example for both classic ASP.NET and ASP.NET Core confuse readers about when the call is meaningful.
They omit the diagnostic dump workflow. Most articles jump from “your app is hanging” to “here are the fixes.” The actual workflow is to confirm it is a deadlock (not slow I/O), capture a dump with dotnet-dump, inspect !syncblk output, and locate the blocking call site before applying any fix. The Diagnostic Timeline section above is the part most other guides skip entirely.
They confuse .Result with .GetAwaiter().GetResult(). The two have the same blocking semantics: both will deadlock under a captured sync context. The difference is in how they unwrap exceptions. .Result wraps the inner exception in AggregateException; .GetAwaiter().GetResult() rethrows the original. Articles that present them as different fixes for the deadlock are wrong; the deadlock behavior is identical.
They treat thread pool starvation as the same problem. Thread pool starvation can present as a hang under load even when no sync context is involved, but the cause and fix are different. The diagnostic signal (ThreadPool Queue Length climbing without bound) distinguishes the two cases. Articles that lump them together send readers down the wrong fix path.
Frequently Asked Questions
What is the difference between .Result, .Wait(), and .GetAwaiter().GetResult()?
All three block the calling thread until the task completes, and all three can deadlock under a captured synchronization context. The differences are in exception handling: .Result and .Wait() wrap inner exceptions in an AggregateException, while .GetAwaiter().GetResult() rethrows the original exception directly. For debugging purposes, .GetAwaiter().GetResult() produces cleaner stack traces. None of the three is preferred over the others for deadlock avoidance; the right answer is to not block at all (Fix 1).
Why does my code only deadlock in WPF / classic ASP.NET but not in Console apps?
Console apps use the default thread pool synchronization context, which does not force continuations to run on any specific thread. WPF uses DispatcherSynchronizationContext (continuations must return to the UI thread); WinForms uses WindowsFormsSynchronizationContext (same); classic ASP.NET uses AspNetSynchronizationContext (only one thread per request at a time). The blocking call holds the only thread that the continuation needs, and neither side can make progress. Console apps have no such constraint.
Should I use ConfigureAwait(false) everywhere?
In library code, yes. In application code, no. The original recommendation from Microsoft and Stephen Toub has nuanced over time: use it on every await in code that gets shipped as a NuGet package or shared library, because you do not know what context the caller will run under. In application code (controllers, UI handlers, scripts) you control the context and the “async all the way” pattern makes ConfigureAwait(false) unnecessary. .NET 8 added a ConfigureAwait(ConfigureAwaitOptions) overload with finer-grained control, which is worth using once you are on .NET 8 or later.
What is async void and when can I use it?
async void is the one method shape in C# where an async method does not return a Task. It is fire-and-forget: the caller cannot await it, exceptions thrown inside it crash the process instead of being captured, and it does not compose with normal async patterns. The only legitimate use is event handlers (Button_Click etc.) where the event delegate signature requires void. Everywhere else, return Task or Task<T>.
Does this deadlock happen in ASP.NET Core?
No, not the classic version. ASP.NET Core removed the synchronization context from request threads in .NET Core 1.0 (June 2016), which eliminates the specific .Result-blocks-only-thread-needed-by-continuation deadlock. If your ASP.NET Core app hangs, the cause is something else: thread pool starvation, lock contention, a logical deadlock, or HttpClient socket exhaustion. The Edge Cases section above covers the most common alternatives.
What does Stephen Toub’s “ConfigureAwait FAQ” actually recommend?
The post is the canonical reference and is worth reading end-to-end. The short version: ConfigureAwait(false) is not a “best practice” to apply mechanically; it is a tool with a specific purpose (decoupling library code from caller contexts). In ASP.NET Core application code it is unnecessary. In WPF/WinForms application code it is usually wrong (you need the UI context for the continuation). In library code it is almost always correct. Read the post once and you will stop guessing about whether to add it.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: ASP.NET 500 Internal Server Error
Fix ASP.NET 500 Internal Server Error by enabling developer exception pages, fixing DI registration, connection strings, and middleware configuration.
Fix: C# Cannot implicitly convert type 'X' to 'Y'
How to fix C# cannot implicitly convert type error caused by type mismatches, nullable types, async return values, LINQ result types, and generic constraints.
Fix: C# TaskCanceledException: A task was canceled
How to fix C# TaskCanceledException A task was canceled caused by HttpClient timeouts, CancellationToken, request cancellation, and Task.WhenAll failures.
Fix: joblib Not Working — Parallel Backends, Memory Cache, and Pickling Errors
How to fix joblib errors — Parallel n_jobs slower than expected, Memory cache miss, backend loky vs threading vs multiprocessing, pickling lambda not supported, dump load file size, and pytest interference.