SynchronizationContext Post and Send, UI and legacy ASP.NET contexts, ASP.NET Core having none, and the blocking-on-async deadlock.
A SynchronizationContext is an abstraction for "run this code in the right place", with Post (asynchronous) and Send (synchronous). WinForms, WPF, MAUI and Blazor install one so continuations return to the UI thread or renderer; legacy ASP.NET had one per request; ASP.NET Core and console apps have none. await captures the current context and posts the continuation to it. The classic deadlock: UI code calls .Result on an async method, blocking the only UI thread, while the method's continuation waits to be posted back to that same thread. Neither can proceed.
"A
SynchronizationContextis an abstraction for 'run this code in the right place'. It hasPost(queue it and return) andSend(run it and wait). UI frameworks (WinForms, WPF, MAUI) install one so work returns to the UI thread. Blazor installs one so component updates are serialised. Legacy ASP.NET on .NET Framework had one per request. ASP.NET Core, console apps and worker services have none. When youawaitan incomplete task, the awaiter capturesSynchronizationContext.Currentand later posts the continuation back to it. The classic deadlock follows from that. UI code calls.Resulton an async method, which blocks the only UI thread. Meanwhile the async method's continuation is waiting to be posted back to that same UI thread. Each waits for the other, forever, with zero CPU. The fix is toawaitall the way up. Library code usesConfigureAwait(false)so its continuations don't need the caller's context. In ASP.NET Core this exact deadlock doesn't happen, but blocking still causes ThreadPool starvation, so the advice is the same."
| Member | Meaning |
|---|---|
Post(callback, state) |
Queue to the target "place" and return immediately (used by await) |
Send(callback, state) |
Run on the target and wait for completion (Control.Invoke style) |
SynchronizationContext.Current |
The context for the current thread (null if none) |
OperationStarted/Completed |
Used by async void to track outstanding operations |
| App model | Context | Effect |
|---|---|---|
| WinForms | WindowsFormsSynchronizationContext |
Continuations return to the UI thread |
| WPF | DispatcherSynchronizationContext |
Same, via the Dispatcher |
| MAUI | Platform UI context | Same |
| Blazor Server / WebAssembly | Renderer context | One logical thread for component state |
| ASP.NET (.NET Framework) | AspNetSynchronizationContext |
One thread at a time per request; HttpContext.Current |
| ASP.NET Core, console, worker service | None | Continuations run on the ThreadPool |
| Some test frameworks | Their own | Behaviour may differ from production |
| Step | UI thread | Async method |
|---|---|---|
| 1 | Click handler calls LoadAsync().Result |
Starts, reaches await http.GetAsync(...) |
| 2 | — | Incomplete → captures UI context, returns incomplete task |
| 3 | Blocks in .Result |
— |
| 4 | — | HTTP completes → Post continuation to UI context |
| 5 | Still blocked: can't run the posted continuation | Waits for the UI thread |
| 6 | Deadlock: 0% CPU, frozen window |
| Fix | Effect |
|---|---|
await all the way up (async void OK for the event handler only) |
No blocking; UI stays responsive |
ConfigureAwait(false) throughout library code |
Library continuations don't need the caller's context, so a blocking caller won't deadlock (but is still blocked) |
At an unavoidable sync boundary: Task.Run(() => LoadAsync()).GetAwaiter().GetResult() |
Runs async code without the context; still blocks the caller (last resort) |
| Environment | Blocking on async | ConfigureAwait(false) |
|---|---|---|
| WPF / WinForms / MAUI | Deadlock | Yes in libraries; not in code that touches controls |
| Blazor | Deadlock risk | As UI |
| Legacy ASP.NET | Deadlock | Yes in libraries |
| ASP.NET Core | ThreadPool starvation (no context deadlock) | Harmless; optional in app code |
| Console / worker | Starvation risk | Optional |
Our [WPF line-of-business app] froze on start-up after a developer added a new async settings loader and called it from the main window's constructor with .Result. The window never appeared, CPU was at [0%], and nothing was logged. A dump of the hung process showed the UI thread in Task.Wait (under .Result), and dumpasync showed the loader's continuation queued to the Dispatcher, waiting for that same thread. I moved the load into an async Loaded event handler that awaited the loader and showed a busy indicator meanwhile, and added ConfigureAwait(false) inside the loader library. We also added the Microsoft.VisualStudio.Threading.Analyzers package so synchronous waits on the UI thread are flagged at build time.
Checks a classic senior async debugging topic.
Green flags
ConfigureAwait(false) in libraries.Red flags
await captures and resumesawait task (incomplete):
ctx = SynchronizationContext.Current
if (ctx != null && ctx.GetType() != typeof(SynchronizationContext))
register continuation: on completion -> ctx.Post(MoveNext)
else if (TaskScheduler.Current != TaskScheduler.Default)
register continuation on that scheduler
else
register continuation to run on the pool (or inline on the completing thread when safe)
ConfigureAwait(false) skips the first two branches.
The code example builds a tiny single-threaded SynchronizationContext (a queue processed by one thread), just like a UI message loop:
fake-ui thread runs callback:
LoadAsync() -> await Task.Delay(100) captures fake-ui context
.GetResult() blocks fake-ui thread
Delay completes -> Post(continuation) to fake-ui queue
fake-ui thread is blocked -> queue never processed -> deadlock
With avoidContext: true (ConfigureAwait(false)), the continuation runs on the pool, completes the task, and .GetResult() returns.
Send deadlocks too// UI thread:
var t = Task.Run(() => uiContext.Send(_ => label.Text = "x", null)); // background thread waits for UI
t.Wait(); // UI thread waits for background -> deadlock
Prefer Post / BeginInvoke / Dispatcher.InvokeAsync, or just await.
AspNetSynchronizationContext serialised continuations per request and carried HttpContext.Current, costing throughput and causing the deadlock class above.HttpContext explicitly (DI, parameters) or via IHttpContextAccessor (AsyncLocal).async void and the contextasync void methods call OperationStarted / OperationCompleted on the context and post exceptions to it. On a UI thread, an exception from an async void handler goes to the dispatcher's unhandled-exception handler; with no context, it crashes the process.
| Clue | Meaning |
|---|---|
UI thread top frames: Task.Wait, TaskAwaiter.GetResult, .Result |
Blocking on async |
dumpasync shows a state machine whose continuation targets the dispatcher/context |
Waiting for the blocked thread |
| CPU ~0%, no exceptions | Classic deadlock signature |
xUnit v2 installs a context (to limit parallelism); some UI test harnesses install dispatcher contexts. Code that blocks on async may deadlock in one test runner and not another. Make tests async Task and await.
| Version | Change |
|---|---|
| .NET Framework 2.0 | SynchronizationContext |
| .NET 4.5 / C# 5 | await captures context by default |
| ASP.NET Core 1.0 | No request context |
| .NET 8 | ConfigureAwaitOptions (see next question) |
| .NET 10 | Same model |
avoidContext: false to see "Deadlocked", then true to see "Completed"..Result on an async method from a button click and observe the freeze; then make the handler async void and await.ConfigureAwait(false) in the async method and confirm .Result no longer deadlocks (but still blocks the UI).SynchronizationContext.Current?.GetType().Name in WPF, Blazor, ASP.NET Core and a console app.clrstack and dumpasync.using System.Threading;
// Simulate a UI-style single-threaded context to reproduce the deadlock in a console app.
var context = new SingleThreadContext();
var done = new ManualResetEventSlim();
context.Post(_ =>
{
SynchronizationContext.SetSynchronizationContext(context);
var task = LoadAsync(avoidContext: true); // set false to deadlock
Console.WriteLine($"Result: {task.GetAwaiter().GetResult()}"); // blocks the context thread
done.Set();
}, null);
Console.WriteLine(done.Wait(TimeSpan.FromSeconds(2)) ? "Completed" : "Deadlocked");
static async Task<string> LoadAsync(bool avoidContext)
{
// continueOnCapturedContext: false is the same as ConfigureAwait(false).
await Task.Delay(100).ConfigureAwait(continueOnCapturedContext: !avoidContext);
return "settings";
}
// A minimal single-threaded SynchronizationContext, like a UI thread's.
public sealed class SingleThreadContext : SynchronizationContext
{
private readonly System.Collections.Concurrent.BlockingCollection<(SendOrPostCallback, object?)> _queue = new();
public SingleThreadContext()
{
var thread = new Thread(() =>
{
foreach (var (callback, state) in _queue.GetConsumingEnumerable()) callback(state);
}) { IsBackground = true, Name = "fake-ui" };
thread.Start();
}
public override void Post(SendOrPostCallback d, object? state) => _queue.Add((d, state));
}
What happens: .Result / .Wait() / GetAwaiter().GetResult() in a handler or constructor.
Why it is wrong: the continuation needs the blocked thread → deadlock.
Do instead: make the caller async and await; move work out of constructors.
What happens: HttpContext.Current-style code or deadlock-driven ConfigureAwait rules are applied.
Why it is wrong: there's no context; continuations run on any pool thread.
Do instead: pass HttpContext explicitly; still avoid blocking because it starves the pool.
ConfigureAwait(false)What happens: label.Text = ... after an awaited call configured with false.
Why it is wrong: the code is on a pool thread → cross-thread exception.
Do instead: use ConfigureAwait(false) only in code that doesn't touch the UI.
Send / Invoke from a thread the UI is waiting onWhat happens: background work calls Control.Invoke while the UI thread waits for that work.
Why it is wrong: mutual wait → deadlock.
Do instead: BeginInvoke/InvokeAsync, or await.
ConfigureAwait(false) while still blockingWhat happens: the deadlock disappears but .Result stays on the UI thread.
Why it is wrong: the UI still freezes for the duration of the call.
Do instead: remove the blocking call.
What happens: constructors call async methods and block on them.
Why it is wrong: constructors can't be async; blocking risks deadlock.
Do instead: async factory methods, or load in an async lifecycle event (Loaded, OnInitializedAsync).
async void for non-handler methods in UI appsWhat happens: helper methods are async void.
Why it is wrong: callers can't await them; exceptions go to the dispatcher or crash.
Do instead: async Task.
What happens: code deadlocks in xUnit but not in the app (or vice versa). Why it is wrong: different contexts change behaviour. Do instead: write async tests and never block in tested code.
Task.Run(...).Result as the default fixWhat happens: every sync-over-async call is wrapped this way. Why it is wrong: avoids the deadlock but still blocks a thread and adds a hop. Do instead: keep it for rare, unavoidable sync boundaries.
What happens: Blazor components block on async calls or mutate state from background threads.
Why it is wrong: deadlocks or renderer exceptions.
Do instead: await, and use InvokeAsync(StateHasChanged) from background callbacks.
Knowing which app model you are in decides the right advice. In UI apps the context is essential and blocking deadlocks; in ASP.NET Core there is no context but blocking still causes starvation. Libraries must work in both, which is why they use ConfigureAwait(false).
| Environment | Blocking on async | ConfigureAwait(false) needed? |
|---|---|---|
| WPF/WinForms/MAUI | Deadlock | Yes in libraries; no in UI code that touches controls |
| Blazor | Deadlock risk | Same as UI |
| Legacy ASP.NET | Deadlock | Yes in libraries |
| ASP.NET Core | Starvation, not this deadlock | Harmless, optional in app code |
| Console/worker | Starvation risk | Optional |
ConfigureAwait(false) tells an await not to resume on the captured SynchronizationContext or custom TaskScheduler; the continuation runs on a ThreadPool thread instead. Use it in general-purpose library code, which should not depend on or block the caller's context, to avoid deadlocks and save context switches. Do not use it in UI code that must touch controls after the await. In ASP.NET Core application code it is optional because there is no context. Since .NET 8, ConfigureAwait(ConfigureAwaitOptions) adds SuppressThrowing (await a Task without throwing) and ForceYielding (always continue asynchronously).
async tells the compiler to rewrite the method into a state machine and lets you use await inside it; it does not create a thread. At await x, the code calls x.GetAwaiter() and checks IsCompleted. If it is already complete, execution continues synchronously. If not, the method registers the rest of itself as a continuation, captures the current SynchronizationContext (or non-default TaskScheduler), and returns an incomplete task to its caller, releasing the thread. When the awaited work finishes, the continuation resumes, on the captured context if there was one, otherwise on the ThreadPool. Exceptions and results flow through the returned task.
How to tell it (STAR)
Our [WPF app] froze at start-up with no CPU. The dump showed the UI thread blocked on `.Result` and the continuation waiting for that same thread. I moved the load into an async `Loaded` handler and added threading analysers so the pattern is flagged at build time.
Last updated 31 Oct 2026.