Blocking on tasks with .Result, .Wait and GetResult; hidden places such as constructors, DI factories, Dispose and sync interfaces; async initialisation patterns and safe boundaries.
Sync-over-async is synchronously blocking on asynchronous work: .Result, .Wait(), GetAwaiter().GetResult() or Task.WaitAll. It holds a thread for the whole operation, causes ThreadPool starvation on servers and deadlocks under a SynchronizationContext. It sneaks in through constructors, DI factory lambdas, property getters, Dispose, lock blocks, synchronous interfaces (IComparer, old filters), static initialisers and "quick fixes" in legacy code. Remove it by making the call chain async, using async factory methods or lazy async initialisation, IAsyncDisposable, hosted-service warm-up, and async-capable interfaces. Where a sync boundary is unavoidable (for example a legacy interface), keep it at the edge and document it.
"Sync-over-async is synchronously blocking on asynchronous work:
.Result,.Wait(),GetAwaiter().GetResult(),Task.WaitAll, orWaitHandlewaits on a task. It holds a thread for the whole operation, which causes ThreadPool starvation on servers and deadlocks under aSynchronizationContextin UI and legacy ASP.NET apps. It rarely appears in obvious request code; it sneaks in whereawaitisn't available or seems awkward: constructors, DI factory lambdas, property getters,Dispose, insidelockblocks, synchronous interfaces (validators, comparers, old filters), static initialisers,Mainin old code, and third-party SDKs. I remove it by making the call chain async and by using the right patterns: async factory methods, lazy async initialisation, start-up initialisation in hosted services,IAsyncDisposable,SemaphoreSliminstead oflock, and async-capable interfaces. Switching.ResulttoGetAwaiter().GetResult()isn't a fix: only the exception shape changes. Where a sync boundary is genuinely unavoidable, I keep it at the edge, off hot paths, and document it, and I enforce the rest with analysers."
| Environment | Consequence |
|---|---|
| ASP.NET Core / workers | One blocked pool thread per call → starvation under load |
| UI (WPF, WinForms, MAUI, Blazor) | Deadlock when the continuation needs the blocked UI thread |
| Legacy ASP.NET | Request-context deadlock |
| Any | .Result/.Wait() wrap exceptions in AggregateException; timeouts and cancellation become awkward |
| Form | Blocks? | Exception shape |
|---|---|---|
task.Result |
Yes | AggregateException |
task.Wait() |
Yes | AggregateException |
task.GetAwaiter().GetResult() |
Yes | Original exception |
Task.WaitAll(...) / WaitAny |
Yes | AggregateException |
((IAsyncResult)task).AsyncWaitHandle.WaitOne() |
Yes | None (you check later) |
| Place | Example | Replace with |
|---|---|---|
| Constructor | _config = LoadAsync().Result; |
static async Task<T> CreateAsync() factory |
| DI factory | AddSingleton(sp => Client.CreateAsync().Result) |
Initialise at start-up (hosted service) and register the instance; or an async accessor |
| Property getter | Settings => _store.GetAsync().Result |
Task<Settings> GetSettingsAsync() |
Dispose |
_stream.FlushAsync().Wait() |
IAsyncDisposable + await using |
Inside lock |
lock (_g) { x = GetAsync().Result; } |
SemaphoreSlim.WaitAsync |
| Sync interface | IValidator.Validate calling an async repo |
Async interface, or precompute data before validation |
Main |
RunAsync().Wait() |
static async Task Main / top-level await |
| Event handler needing a result | Blocking to return a value | Restructure: async handler + state update |
| Third-party SDK | Library internally blocks | Upgrade to its async API; isolate if impossible |
| Pattern | Use when |
|---|---|
| Async factory method | The object can't exist before I/O completes |
Lazy<Task<T>> / AsyncLazy |
First use triggers I/O; all callers await the same task |
| Hosted service warm-up | App-wide dependencies to load before traffic |
IAsyncDisposable |
Cleanup involves I/O (flush, close connection) |
| Readiness probe | Don't route traffic until warm-up completes |
.Result, .Wait(, GetAwaiter().GetResult(), WaitAll; enable CA1849, VSTHRD002, VSTHRD103 as warnings to list them.async propagate to callers; add Async overloads alongside sync ones temporarily if needed.ConfigureAwait(false) throughout the called code.Our [ASP.NET Core API] registered a secrets-backed pricing client with AddSingleton(sp => PricingClient.CreateAsync(...).Result). After every deployment, the first [30–60 seconds] of traffic timed out and the pool queue spiked; later traffic was fine. A dump taken during start-up showed many request threads waiting inside the DI factory on the same initialisation task: the singleton was being created lazily on the first requests, and every concurrent request blocked on it. I changed creation to an async factory called from a hosted service's StartAsync, which completes before the app accepts traffic, registered the result through a holder, and added a readiness check. Post-deploy timeouts disappeared, and we enabled VSTHRD002 and CA1849 to flag the pattern at build time.
Checks code-review-level async discipline.
Green flags
IAsyncDisposable, hosted services.Red flags
GetAwaiter().GetResult() is safe..Result as normal.GetAwaiter().GetResult() isn't "safe"| Claim | Reality |
|---|---|
| "It doesn't deadlock" | It deadlocks exactly like .Result under a SynchronizationContext |
| "It doesn't block" | It blocks the calling thread until completion |
| "It's better" | Only in exception shape (original exception instead of AggregateException) |
Use it only at genuine sync boundaries, with ConfigureAwait(false) throughout the called code.
public sealed class SearchIndex
{
private readonly IndexData _data;
private SearchIndex(IndexData data) => _data = data; // cheap, synchronous
public static async Task<SearchIndex> CreateAsync(IIndexStore store, CancellationToken ct)
{
var data = await store.LoadAsync(ct); // I/O happens here, explicitly
return new SearchIndex(data);
}
}
public sealed class CatalogProvider
{
private Lazy<Task<Catalog>> _catalog;
public CatalogProvider() => _catalog = Create();
private Lazy<Task<Catalog>> Create() =>
new(() => LoadCatalogAsync(), LazyThreadSafetyMode.ExecutionAndPublication);
public async Task<Catalog> GetAsync()
{
try { return await _catalog.Value; }
catch
{
Interlocked.Exchange(ref _catalog, Create()); // don't cache a failure forever
throw;
}
}
}
IHostedService.StartAsync runs during host start. In the generic host, the app's hosted services are started as part of startup and the host doesn't report started until they complete their StartAsync; ordering relative to the server depends on registration and hosting model, so verify with a readiness probe rather than assuming.IHostedLifecycleService adds StartingAsync/StartedAsync hooks.StartAsync short and bounded by its cancellation token; long warm-ups belong behind a readiness check.Task.SpinThenBlockingWait
Task.InternalWaitCore
TaskAwaiter.HandleNonSuccessAndDebuggerNotification
MyApp.PricingClientFactory.<>c.<Register>b__0_0 <-- sync-over-async site
Microsoft.Extensions.DependencyInjection... <-- during DI resolution
| Rule | Catches |
|---|---|
| CA1849 | Calling sync methods when an async alternative exists in async methods |
| CA2012 | ValueTask misuse (often appears alongside) |
| VSTHRD002 | Synchronous waits on tasks |
| VSTHRD103 | Calling sync methods in async methods when async ones exist |
| VSTHRD100 | async void methods |
| Version | Change |
|---|---|
| C# 7.1 | async Main |
| C# 8 / .NET Core 3.0 | IAsyncDisposable, await using |
| C# 9 | Top-level statements with await |
| .NET 8 | IHostedLifecycleService |
| .NET 10 | Same guidance; analysers continue to improve |
.Result into an async factory.Dispose that calls .Wait() with IAsyncDisposable and await using.var builder = WebApplication.CreateBuilder(args);
// BAD: blocks a thread during DI resolution, possibly per first request.
// builder.Services.AddSingleton(_ => PricingClient.CreateAsync(CancellationToken.None).Result);
// GOOD: initialise once at start-up, before traffic, then register the instance holder.
builder.Services.AddSingleton<PricingClientHolder>();
builder.Services.AddHostedService<PricingWarmUp>();
var app = builder.Build();
app.MapGet("/price/{sku}", (string sku, PricingClientHolder holder) => holder.Client.Price(sku));
app.Run();
public sealed class PricingClient
{
private readonly Dictionary<string, decimal> _prices;
private PricingClient(Dictionary<string, decimal> prices) => _prices = prices;
// Async factory instead of an async-in-constructor hack.
public static async Task<PricingClient> CreateAsync(CancellationToken ct)
{
await Task.Delay(200, ct); // load prices from a remote store
return new PricingClient(new() { ["ABC"] = 9.95m });
}
public decimal Price(string sku) => _prices.GetValueOrDefault(sku);
}
public sealed class PricingClientHolder
{
private PricingClient? _client;
public PricingClient Client => _client ?? throw new InvalidOperationException("Not initialised yet");
public void Set(PricingClient client) => _client = client;
}
public sealed class PricingWarmUp(PricingClientHolder holder) : IHostedService
{
public async Task StartAsync(CancellationToken ct) => holder.Set(await PricingClient.CreateAsync(ct));
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
}
.Result in constructorsWhat happens: constructors load data with .Result.
Why it is wrong: blocks threads, can deadlock; constructors can't be async.
Do instead: async factory or hosted-service initialisation.
GetAwaiter().GetResult() as a fixWhat happens: .Result replaced across the codebase.
Why it is wrong: still blocks and still deadlocks under a context.
Do instead: make callers async.
DisposeWhat happens: Dispose calls FlushAsync().Wait().
Why it is wrong: blocks, can deadlock during shutdown.
Do instead: IAsyncDisposable + await using.
What happens: AddSingleton(sp => CreateAsync().Result).
Why it is wrong: first requests block on initialisation; many threads pile up.
Do instead: start-up initialisation and register the instance.
What happens: public Settings Settings => _store.GetAsync().Result;.
Why it is wrong: hidden blocking on every access.
Do instead: explicit async methods.
.Result inside lockWhat happens: async code needs mutual exclusion and blocks inside a lock.
Why it is wrong: blocks while holding a lock: starvation and deadlocks.
Do instead: SemaphoreSlim.WaitAsync.
Lazy<Task<T>> foreverWhat happens: the first load fails and every later call rethrows. Why it is wrong: a transient error becomes permanent until restart. Do instead: reset the lazy on failure.
What happens: analysers are clean but an SDK blocks internally. Why it is wrong: starvation persists. Do instead: check production stacks; upgrade or isolate the SDK.
Async overloads that call sync codeWhat happens: Task<T> GetAsync() => Task.FromResult(Get());.
Why it is wrong: async-over-sync: still blocks the caller.
Do instead: implement real async I/O from the leaf upward.
What happens: sync-over-async creeps back in new code. Why it is wrong: the next incident is a matter of time. Do instead: analysers as errors plus a review checklist.
Sync-over-async is a code-review topic more than a runtime one: once it ships, it fails at the worst moment (peak load, start-up, shutdown). I enforce analysers, keep a list of the few approved sync boundaries, and refactor towards async factories and hosted-service initialisation.
| Where | Replace with |
|---|---|
| Constructor | Async factory or hosted service init |
| DI factory | Start-up initialisation |
Dispose |
IAsyncDisposable |
lock + .Result |
SemaphoreSlim.WaitAsync |
| Legacy sync interface | Edge adapter, documented, off hot paths |
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.
ThreadPool starvation happens when pool threads are blocked faster than the pool can add new ones, so queued work (requests, continuations, timers) waits. Symptoms: latency rises sharply while CPU stays low, the pool queue length grows, thread count climbs slowly, health checks time out and Kestrel logs heartbeat warnings mentioning possible starvation. I confirm it with dotnet-counters (queue length and thread count), then take dotnet-stack snapshots or a dump to see what the threads are blocked on, usually .Result, .Wait(), synchronous I/O or locks. The fix is to make that code asynchronous; raising SetMinThreads only buys time.
Implement IAsyncDisposable when cleanup involves asynchronous I/O, such as flushing a buffered stream, sending a final message or closing a network connection gracefully. DisposeAsync() returns ValueTask. await using var x = ...; compiles to try/finally with await x.DisposeAsync(), and you can write await using (x.ConfigureAwait(false)) in library code. A type that supports both should implement IDisposable and IAsyncDisposable, put async cleanup in protected virtual ValueTask DisposeAsyncCore(), and call GC.SuppressFinalize. In DI, use CreateAsyncScope(): disposing a provider or scope synchronously throws when it holds a service that implements only IAsyncDisposable.
How to tell it (STAR)
Every deployment our [API] timed out for the first minute. The dump showed request threads all blocked in a DI factory that called `.Result` on async client creation. I moved creation into a hosted service that finished before traffic arrived. The post-deploy timeouts disappeared and analysers now flag this pattern.
Last updated 31 Oct 2026.