Context capture, library versus application code, the already-completed caveat, ASP.NET Core relevance, CA2007 and ConfigureAwaitOptions.
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).
"
ConfigureAwait(false)tells oneawaitnot to capture the currentSynchronizationContextor non-defaultTaskScheduler. When the awaited task completes, the rest of the method runs on a ThreadPool thread, or inline on whatever thread completed the task, instead of being posted back to the original context. That matters in two places. In library code that might run under any app model (WPF, WinForms, Blazor, legacy ASP.NET), it avoids the classic blocking deadlock and saves needless hops onto the UI thread, so libraries should use it on every await. In UI code, you must not use it before touching controls, because you'd be on the wrong thread. In ASP.NET Core app code it's optional, because there's no context to capture. It is not a fix for.Resultor.Wait(). Since .NET 8 there's an overload takingConfigureAwaitOptions:SuppressThrowingto await aTaskwithout it throwing, andForceYieldingto always continue asynchronously."
| Code | Captures context? | Continuation runs on |
|---|---|---|
await task (= ConfigureAwait(true)) |
Yes | Captured SynchronizationContext / scheduler, else pool |
await task.ConfigureAwait(false) |
No | Pool (or inline on the completing thread) |
Task already complete at the await |
— | Current thread: no switch at all, either way |
| Code | Use ConfigureAwait(false)? |
Why |
|---|---|---|
| NuGet / shared library | Yes, every await | Unknown app model; avoid deadlocks and UI-thread hops |
| UI event handlers, view models updating bindings | No (before UI access) | Must resume on the UI thread |
| ASP.NET Core controllers / services | Optional | No context exists; no behaviour difference |
| Console / worker | Optional | No context |
| Code shared by UI and server | Yes in non-UI layers | Keeps lower layers context-free |
Analyser CA2007 ("do not directly await a Task") enforces it in library projects.
| Caveat | Consequence |
|---|---|
| Applies to one await | Later awaits need it too |
| If the awaited task was already complete, no switch happens | You can still be on the UI thread after a ConfigureAwait(false) await |
| Doesn't make blocking safe | .Result may no longer deadlock but still blocks a thread (and the UI) |
ExecutionContext still flows |
AsyncLocal, activity, culture flow regardless |
ConfigureAwaitOptions (.NET 8+)| Option | Meaning |
|---|---|
None |
Same as ConfigureAwait(false) |
ContinueOnCapturedContext |
Same as ConfigureAwait(true) |
SuppressThrowing |
The await completes without throwing if the task faulted or was canceled (non-generic Task only); inspect the task afterwards |
ForceYielding |
Always yield (behaves like await Task.Yield()), even if the task is already complete |
Flags combine: ConfigureAwaitOptions.SuppressThrowing | ConfigureAwaitOptions.ContinueOnCapturedContext.
| Construct | How to configure |
|---|---|
ValueTask / ValueTask<T> |
.ConfigureAwait(false) |
await foreach |
source.WithCancellation(ct).ConfigureAwait(false) |
await using |
await using (resource.ConfigureAwait(false)) |
Our [shared HTTP client library] was used by an ASP.NET Core service and by a [WPF desktop app]. The service never had problems, but one legacy WPF screen froze whenever it called the library synchronously with .Result. The library awaited without ConfigureAwait(false), so its continuations tried to resume on the WPF Dispatcher, which was blocked by that .Result. I added ConfigureAwait(false) to every await in the library and turned on CA2007 as an error for library projects, which removed the freeze and reduced work on the UI thread. Then we fixed the root cause by making the legacy screen await properly, and added CA2007 to our library project template.
Checks understanding rather than habit.
Green flags
ConfigureAwaitOptions.Red flags
.Result safe.task.ConfigureAwait(false) returns a ConfiguredTaskAwaitable whose awaiter registers the continuation with continueOnCapturedContext: false:
awaiter.UnsafeOnCompleted(continuation):
if continueOnCapturedContext:
capture SynchronizationContext.Current (if non-default) or TaskScheduler.Current (if non-default)
else:
no capture -> continuation runs on the pool or synchronously on the completing thread
public async Task SaveAsync()
{
await _cache.GetAsync().ConfigureAwait(false); // cache hit: already complete -> still on UI thread
DoCpuHeavyWork(); // runs on the UI thread!
await _http.PostAsync(...).ConfigureAwait(false); // incomplete -> switches to the pool
}
That's why libraries apply it to every await. If you need to guarantee you've left the context now, use await Task.Yield() (which honours the context, careful) or, in .NET 8+, await someTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding), or offload with Task.Run.
SuppressThrowing in practice// Shutdown: wait for background work to finish, log failures, never throw from here.
await _backgroundTask.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
if (_backgroundTask.IsFaulted)
_log.LogError(_backgroundTask.Exception, "Background loop failed");
Restrictions: not supported for Task<TResult> (throws ArgumentOutOfRangeException), because there would be no result to return. Cast to Task if you only need completion.
With no SynchronizationContext and the default scheduler, the "captured" and "not captured" paths behave the same, so ConfigureAwait(false) makes no functional difference in app code there. Teams often omit it in ASP.NET Core apps for readability, while keeping it in shared libraries.
| Rule | Behaviour |
|---|---|
| CA2007 | Flags awaits without ConfigureAwait (enable for library projects) |
| VSTHRD111 (VS threading analyzers) | Similar, with UI-thread awareness |
ConfigureAwaitChecker / Fody ConfigureAwait |
Alternative enforcement/weaving approaches |
| Version | Change |
|---|---|
| .NET 4.5 | ConfigureAwait(bool) |
| .NET Core 3.0 | ConfigureAwait for IAsyncEnumerable / IAsyncDisposable |
| .NET 8 | ConfigureAwait(ConfigureAwaitOptions) with SuppressThrowing, ForceYielding |
| .NET 10 | Unchanged |
await Task.Delay(100).ConfigureAwait(false) and see the cross-thread exception; remove it and it works.SynchronizationContext.Current == null and the thread id before and after awaits with and without ConfigureAwait(false).Task.CompletedTask.ConfigureAwait(false) on the UI thread and confirm you're still on the UI thread.SuppressThrowing lets you inspect the faulted task without try/catch.SuppressThrowing on a Task<int> and observe the exception.using System.Net.Http;
var client = new WeatherClient(new HttpClient { BaseAddress = new Uri("https://example.com/") });
try
{
Console.WriteLine(await client.GetForecastAsync("Sydney"));
}
catch (HttpRequestException ex)
{
Console.WriteLine($"Request failed: {ex.Message}");
}
// .NET 8+: wait for a task without throwing, then inspect it.
Task risky = FailLaterAsync();
await risky.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
Console.WriteLine($"Faulted: {risky.IsFaulted}");
static async Task FailLaterAsync()
{
await Task.Delay(10);
throw new InvalidOperationException("background failed");
}
// Library code: no dependency on the caller's context.
public sealed class WeatherClient(HttpClient http)
{
public async Task<string> GetForecastAsync(string city, CancellationToken ct = default)
{
using var response = await http.GetAsync($"forecast/{Uri.EscapeDataString(city)}", ct)
.ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
}
}
ConfigureAwait(false)What happens: a view model sets a bound property after a configured await.
Why it is wrong: the continuation runs on a pool thread → cross-thread exceptions or binding errors.
Do instead: keep the default in UI layers; use false in lower layers.
What happens: ConfigureAwait(false) on the first call only.
Why it is wrong: if that task was already complete, later code still runs on the caller's context.
Do instead: every await in library code (CA2007).
What happens: .Result stays, ConfigureAwait(false) added everywhere "to stop deadlocks".
Why it is wrong: threads are still blocked; the UI still freezes; starvation remains on servers.
Do instead: await all the way up.
SuppressThrowing with Task<T>What happens: await GetAsync().ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing).
Why it is wrong: not supported → ArgumentOutOfRangeException.
Do instead: cast to Task, or use try/catch.
AsyncLocal flowWhat happens: used to "drop" logging scopes or culture.
Why it is wrong: ExecutionContext flows regardless.
Do instead: ExecutionContext.SuppressFlow() if you truly need to.
What happens: every await in controllers has ConfigureAwait(false).
Why it is wrong: no functional benefit there; adds noise.
Do instead: decide per project type; keep it in libraries.
await foreach / await usingWhat happens: library code configures normal awaits but not async streams or disposal.
Why it is wrong: MoveNextAsync/DisposeAsync continuations capture the context.
Do instead: .ConfigureAwait(false) on those too.
What happens: ConfigureAwait(false) used to "get off the UI thread now".
Why it is wrong: no switch if the task was already complete.
Do instead: Task.Run for CPU work, or ForceYielding (.NET 8+).
What happens: code running on a ConcurrentExclusiveSchedulerPair uses ConfigureAwait(false).
Why it is wrong: continuations leave the custom scheduler, breaking serialisation assumptions.
Do instead: keep default capture where the scheduler matters.
What happens: tests pass under one context, production deadlocks under another. Why it is wrong: behaviour depends on the context present. Do instead: test under the real context (UI test harness) or avoid blocking entirely.
My rule is simple: libraries always use ConfigureAwait(false), UI code never does when it touches the UI, and ASP.NET Core app code does not need it. I explain the reason, because cargo-cult usage leads to the UI-thread bugs it was meant to prevent.
| Option | Effect | Typical use |
|---|---|---|
ConfigureAwait(true) (default) |
Resume on context | UI code |
ConfigureAwait(false) |
Resume on pool | Libraries |
SuppressThrowing |
No exception on await | Waiting for background tasks at shutdown |
ForceYielding |
Always asynchronous | Avoid stack dives, yield explicitly |
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.
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)
One [WPF screen] hung calling our shared client, while the server using the same code was fine. The library's continuations were trying to return to the blocked UI thread. I added `ConfigureAwait(false)` throughout the library with CA2007 enforcing it, and then fixed the screen to await properly.
Last updated 31 Oct 2026.