📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What is sync-over-async, where does it sneak into .NET code, and how do you remove it?

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.

Senior CodeReview Advanced async code-review deadlock sync-over-async

🎯 Key points

  • Sync-over-async blocks a thread on async work with .Result, .Wait or GetResult
  • It causes starvation on servers and deadlocks under a SynchronizationContext
  • Hidden places: constructors, DI factories, getters, Dispose, lock blocks, sync interfaces
  • Use async factories, lazy async initialisation and hosted-service warm-up
  • Use IAsyncDisposable for async cleanup
  • GetAwaiter().GetResult() changes the exception, not the blocking
  • Enforce with CA1849 and VSTHRD analysers

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

"Sync-over-async is synchronously blocking on asynchronous work: .Result, .Wait(), GetAwaiter().GetResult(), Task.WaitAll, or WaitHandle waits on a task. It holds a thread for the whole operation, which causes ThreadPool starvation on servers and deadlocks under a SynchronizationContext in UI and legacy ASP.NET apps. It rarely appears in obvious request code; it sneaks in where await isn't available or seems awkward: constructors, DI factory lambdas, property getters, Dispose, inside lock blocks, synchronous interfaces (validators, comparers, old filters), static initialisers, Main in 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, SemaphoreSlim instead of lock, and async-capable interfaces. Switching .Result to GetAwaiter().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."


1. What it costs

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

2. Blocking forms

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)

3. Where it hides and what to use instead

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

4. Patterns that replace blocking initialisation

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

5. Removing it from a legacy codebase

  1. Inventory: search for .Result, .Wait(, GetAwaiter().GetResult(), WaitAll; enable CA1849, VSTHRD002, VSTHRD103 as warnings to list them.
  2. Prioritise by hotness: request paths and startup first (use stacks from production or load tests).
  3. Convert bottom-up: make the leaf I/O async, then let async propagate to callers; add Async overloads alongside sync ones temporarily if needed.
  4. Approved boundaries: keep a short, documented list of unavoidable sync-over-async (legacy interfaces), using ConfigureAwait(false) throughout the called code.
  5. Enforce: flip analysers to errors once the backlog is cleared.

Production example

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.

🔍 What the interviewer is checking

Checks code-review-level async discipline.

Green flags

  • Lists the hidden places (constructors, DI, Dispose, getters).
  • Knows deadlock and starvation consequences.
  • Proposes async factories, lazy async init, IAsyncDisposable, hosted services.
  • Mentions analysers.

Red flags

  • Believes GetAwaiter().GetResult() is safe.
  • Accepts .Result as normal.

📚 Learn in depth

1. Why 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.

2. Async factory

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);
    }
}

3. Lazy async initialisation

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;
        }
    }
}

4. Start-up initialisation in ASP.NET Core

  • 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.
  • .NET 8's IHostedLifecycleService adds StartingAsync/StartedAsync hooks.
  • Keep StartAsync short and bounded by its cancellation token; long warm-ups belong behind a readiness check.

5. Recognising it in dumps and traces

Task.SpinThenBlockingWait
Task.InternalWaitCore
TaskAwaiter.HandleNonSuccessAndDebuggerNotification
MyApp.PricingClientFactory.<>c.<Register>b__0_0   <-- sync-over-async site
Microsoft.Extensions.DependencyInjection...        <-- during DI resolution

6. Analysers

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

7. Version notes

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

8. Try it yourself

  1. Run the code example; then switch to the BAD DI registration and load-test right after start-up.
  2. Refactor a constructor that calls .Result into an async factory.
  3. Implement the lazy async provider and make the first load fail once; verify it retries.
  4. Enable VSTHRD002/CA1849 in a project and fix the top five warnings.
  5. Replace a Dispose that calls .Wait() with IAsyncDisposable and await using.

💻 Code

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;
}

⚠ Common mistakes

1. .Result in constructors

What 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.

2. Treating GetAwaiter().GetResult() as a fix

What happens: .Result replaced across the codebase. Why it is wrong: still blocks and still deadlocks under a context. Do instead: make callers async.

3. Blocking Dispose

What happens: Dispose calls FlushAsync().Wait(). Why it is wrong: blocks, can deadlock during shutdown. Do instead: IAsyncDisposable + await using.

4. Blocking in DI factories

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.

5. Async work in property getters

What happens: public Settings Settings => _store.GetAsync().Result;. Why it is wrong: hidden blocking on every access. Do instead: explicit async methods.

6. .Result inside lock

What 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.

7. Caching a faulted Lazy<Task<T>> forever

What 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.

8. Fixing only your own code

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.

9. Adding Async overloads that call sync code

What 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.

10. No enforcement after cleanup

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.

🏛 Senior insight: trade-offs

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

🔗 Follow-ups the interviewer may ask

  1. When: Candidate mentions deadlocks in UI apps Why: Checks SynchronizationContext
    How to answer

    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.

  2. When: Candidate mentions starvation Why: Checks production diagnosis
    How to answer

    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.

  3. When: Candidate replaces blocking Dispose Why: Checks IAsyncDisposable
    How to answer

    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.

🛠 Real-world problems: how you handle them

Start-up stalls from a blocking DI factory
Situation
An [ASP.NET Core API] registered a secrets-backed client with `AddSingleton(sp => CreateAsync().Result)`.
Signals
After each deployment, the first [30-60 s] of traffic timed out; pool queue spiked; later traffic was fine.
Investigation
Dumps during start-up showed many request threads waiting in the DI factory on the same task.
Resolution
Moved creation to a hosted service that ran before Kestrel accepted traffic and registered the instance through a holder.
Prevention
Enabled VSTHRD002 and CA1849 and added a start-up readiness check.

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.

↩ Usually asked after

More in Concurrency Production Problems

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me