Async and await look trivial from the call site, but that simplicity hides a compiler-generated state machine, a scheduling contract built on SynchronizationContext and TaskScheduler, and an exception model that trips up teams who only know the happy path. Interviewers use this topic to separate developers who can write await SomeAsync(); from developers who can explain why a UI thread deadlocks on .Result, why async void is banned in most style guides, and why a library should almost always call ConfigureAwait(false). At the senior and architect level you are expected to reason about the class the compiler emits, the allocation cost of that state machine on hot paths, and how ambient state — including AsyncLocal<T> — survives a thread hop that async introduces. The questions below work through the internals interviewers actually probe: the state machine, the awaiter pattern, context capture, ConfigureAwait and its ConfigureAwaitOptions overload, async void, exception propagation, and the flow of ExecutionContext.
Q1 What does the C# compiler actually generate for an async method, and how does the state machine drive execution?#
Short answer: The compiler rewrites an async method into a type implementing IAsyncStateMachine, with a MoveNext() method that contains your code split into segments at each await, an integer state field that remembers which segment to resume, and a builder (AsyncTaskMethodBuilder, AsyncTaskMethodBuilder<T>, AsyncValueTaskMethodBuilder<T>, or AsyncVoidMethodBuilder) that owns the returned task and drives the machine forward.
Every local variable that is alive across an await gets hoisted into a field of the state machine, because the method's original stack frame disappears the moment execution returns to the caller. MoveNext() re-enters a big, effectively re-runnable method body guarded by the state field: on first entry it runs until the first await, calls GetAwaiter() on the awaited value, and checks IsCompleted. If the awaited operation is already done, execution falls straight through (synchronous completion, no suspension). If not, the builder registers MoveNext itself as the continuation via AwaitUnsafeOnCompleted, and returns — control goes back to the caller with an incomplete task. When the awaited operation finishes, MoveNext is invoked again, jumps back into the right segment via the state field, and continues.
public async Task<int> LoadLengthAsync(HttpClient client, CancellationToken ct)
{
var response = await client.GetAsync("/status", ct);
return (int)(response.Content.Headers.ContentLength ?? 0);
}
// Conceptually, roughly equivalent to:
private struct LoadLengthAsyncStateMachine : IAsyncStateMachine
{
public int State;
public AsyncTaskMethodBuilder<int> Builder;
public HttpClient Client;
public CancellationToken Ct;
private TaskAwaiter<HttpResponseMessage> _awaiter;
public void MoveNext()
{
int result;
try
{
HttpResponseMessage response;
if (State == 0)
{
response = _awaiter.GetResult();
}
else
{
_awaiter = Client.GetAsync("/status", Ct).GetAwaiter();
if (!_awaiter.IsCompleted)
{
State = 0;
Builder.AwaitUnsafeOnCompleted(ref _awaiter, ref this);
return;
}
response = _awaiter.GetResult();
}
result = (int)(response.Content.Headers.ContentLength ?? 0);
}
catch (Exception ex)
{
State = -2;
Builder.SetException(ex);
return;
}
State = -2;
Builder.SetResult(result);
}
public void SetStateMachine(IAsyncStateMachine machine) => Builder.SetStateMachine(machine);
}What interviewers look for: Understanding that hoisted locals become fields (which explains why async iterator closures can allocate more than you expect), that the builder type depends on the return type, and that the state machine starts as a struct — the runtime only boxes it to the heap the first time it actually suspends, so a method that completes synchronously can avoid a heap allocation entirely.
Common mistakes: Treating await as "just a callback" with no model of state capture; not knowing that a debugger inspecting a suspended async method is really looking at fields on a boxed state machine instance, which is why local variables sometimes show up oddly in dumps.
Follow-up questions:
- Why does the runtime prefer a struct state machine and only box it on first suspension?
- What builder type backs a method that returns
ValueTask<T>, and how does that avoid allocating aTaskobject on the synchronous-completion path?
Q2 What is the awaiter pattern, and what must a type implement to be awaitable?#
Short answer: await is resolved by the compiler through duck typing, not a single interface: any expression is awaitable if it exposes a GetAwaiter() method (as a member or an extension method) that returns a type with a bool IsCompleted property, a GetResult() method, and an OnCompleted(Action) method from INotifyCompletion (optionally UnsafeOnCompleted(Action) from ICriticalNotifyCompletion for a faster path that skips ExecutionContext flow).
Task, Task<T>, ValueTask, ValueTask<T> and Task.Yield()'s YieldAwaitable all follow this shape through their own awaiter structs (TaskAwaiter, ValueTaskAwaiter<T>, and so on). Because the compiler binds structurally, you can make any type awaitable, which is exactly how custom schedulers, WinRT's IAsyncAction, and high-performance library primitives integrate with await without ever touching Task.
public readonly struct DelayAwaiter : ICriticalNotifyCompletion
{
private readonly Timer _timer;
public DelayAwaiter(TimeSpan delay) => _timer = new Timer(_ => { }, null, delay, Timeout.InfiniteTimeSpan);
public bool IsCompleted => false;
public void GetResult() { }
public void OnCompleted(Action continuation) => ScheduleCallback(continuation, flowContext: true);
public void UnsafeOnCompleted(Action continuation) => ScheduleCallback(continuation, flowContext: false);
private void ScheduleCallback(Action continuation, bool flowContext) { /* wire timer callback */ }
}What interviewers look for: Clarity that this is a compile-time pattern, not a runtime interface check for GetAwaiter/IsCompleted/GetResult themselves (only INotifyCompletion and ICriticalNotifyCompletion are real interfaces, because the builder needs to call OnCompleted polymorphically without reflection). Bonus points for knowing await foreach uses a related but distinct pattern (GetAsyncEnumerator/MoveNextAsync/Current), not this exact awaiter shape.
Common mistakes: Assuming await only works on Task-derived types; forgetting that UnsafeOnCompleted exists specifically to skip ExecutionContext capture for performance, which is why hand-written awaiters that care about correctness under AsyncLocal<T> should generally implement OnCompleted faithfully and only add UnsafeOnCompleted when they know it's safe to skip.
Q3 What is SynchronizationContext, how does it relate to TaskScheduler, and how do they decide where a continuation resumes?#
Short answer: SynchronizationContext.Current is the ambient "how do I get back to the right place" abstraction that UI frameworks and legacy ASP.NET install; by default, await captures it and posts the continuation back through it unless you opt out with ConfigureAwait(false). TaskScheduler is the lower-level abstraction the Task Parallel Library itself uses to queue work; if there's no custom SynchronizationContext but a non-default TaskScheduler.Current is active, the awaiter uses that instead, and if neither is present, the continuation simply runs on the thread pool.
The practical difference matters because different hosts install different contexts:
| Host | SynchronizationContext? | Effect on await continuations |
|---|---|---|
| WinForms / WPF | Yes, UI-thread affine | Continuation is marshaled back to the UI thread |
| ASP.NET (classic, System.Web) | Yes, request-affine | Only one thread may run in the context at a time — a major deadlock source |
| ASP.NET Core | No | Continuations resume on any available pool thread |
| Console app / worker service | No, unless you install one | Continuations resume on any available pool thread |
Because ASP.NET Core removed the request-affine context by design, the classic "blocking on async code deadlocks the request" failure mode from System.Web mostly disappears there — but library code still can't assume which host it runs in, which is why the convention of calling ConfigureAwait(false) in non-UI, non-request-handling library code persists.
What interviewers look for: A clear articulation of why ASP.NET Core dropped SynchronizationContext (throughput — no request-thread affinity requirement) and how that changes the deadlock risk profile compared with WPF or classic ASP.NET. Strong candidates connect this directly to the thread pool's growth behavior under blocked continuations.
Common mistakes: Concluding "ASP.NET Core has no context, so ConfigureAwait(false) is pointless there" — it still avoids the (smaller) cost of checking and possibly posting through a scheduler, and library code is routinely reused in hosts that do have a context.
Follow-up questions:
- What happens if a
SynchronizationContext-aware host nests inside code already running on a customTaskScheduler? - Why does
Task.Rundeliberately escape the ambientSynchronizationContext?
Q4 When should you use ConfigureAwait(false), and what do the ConfigureAwaitOptions added in .NET 8 give you beyond the boolean overload?#
Short answer: ConfigureAwait(false) tells that specific await not to marshal its continuation back to the captured SynchronizationContext/TaskScheduler, which avoids context-related deadlocks in blocking callers and trims scheduling overhead; it belongs in library and framework code, not in UI event handlers or code that genuinely needs to resume on the original context. ConfigureAwaitOptions, added in .NET 8, replaces the single boolean with a [Flags] enum — None, ContinueOnCapturedContext, SuppressThrowing, and ForceYielding — for finer control at individual await sites.
// Equivalent to ConfigureAwait(false).
await SomeAsync().ConfigureAwait(ConfigureAwaitOptions.None);
// Swallow a faulted or canceled task's exception right at the await point,
// instead of a separate try/catch — handy for best-effort background work.
await backgroundTask.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
// Force a yield even if the awaited task already completed, so one workflow
// doesn't monopolize a pool thread inside a tight retry or polling loop.
await workItem.ConfigureAwait(ConfigureAwaitOptions.ForceYielding);SuppressThrowing is defined only for the non-generic Task overload — if you have a Task<T>, cast to Task first, because silently swallowing a fault on Task<T> would leave no valid value to hand back. Each await is configured independently: setting ConfigureAwaitOptions.None on one await in a method has no effect on the awaits before or after it.
What interviewers look for: Whether the candidate treats ConfigureAwait(false) as an engineering decision with a real mechanism behind it rather than a slogan repeated without understanding, and whether they know the newer overload exists and why it was added — mixing "suppress the exception" and "continue on captured context" used to require verbose boilerplate.
Common mistakes: Believing one ConfigureAwait(false) call "protects" the rest of the method; calling ConfigureAwait(false) in the outermost await of a UI event handler and then touching controls afterward, which throws a cross-thread exception.
Follow-up questions:
- Does
ConfigureAwait(false)do anything meaningful in ASP.NET Core, where there's noSynchronizationContextto begin with? - When would
ForceYieldingactually improve throughput instead of hurting it?
Q5 Why is async void dangerous, and are there legitimate uses?#
Short answer: async void methods return nothing the caller can await, so there is no Task to observe completion or catch exceptions from; any exception thrown inside one is instead rethrown directly on whatever SynchronizationContext was current when the method started, which typically crashes the process rather than being catchable by the caller. The one sanctioned use is a top-level UI event handler, whose signature is fixed by a delegate type such as RoutedEventHandler, and even there the body should be wrapped in try/catch.
// Dangerous: the caller cannot await it, cannot catch its exceptions, and
// cannot reliably know when it finished — including in tests.
public async void SaveAsync() => await _repository.SaveAsync();
// Preferred: exceptions and completion flow through the returned Task.
public async Task SaveAsync() => await _repository.SaveAsync();
// The one legitimate shape: an event handler forced into this signature.
private async void OnSaveClick(object sender, RoutedEventArgs e)
{
try
{
await SaveAsync();
}
catch (Exception ex)
{
ShowError(ex);
}
}Beyond exception handling, async void methods can't be composed with Task.WhenAll, can't be retried or timed out by a caller, and make unit testing unreliable because a test can complete before the method's continuation has actually run.
What interviewers look for: An explanation of the mechanism (no task to observe, exception rethrown on the captured context) rather than a memorized rule. Strong answers connect this to how a "fire and forget" call site should be implemented instead — an explicit tracked Task, a channel, or a hosted background service, not an unattended async void.
Common mistakes: Using async void for background work "because nothing needs the result," when what's actually needed is fire-and-forget with observed failure, not fire-and-forget with silent process crashes.
Follow-up questions:
- How would you deliberately fire-and-forget a
Taskwhile still logging failures? - What's different about how Blazor or MAUI event handlers behave compared with WPF here?
Q6 How does exception propagation differ between awaiting a task, blocking on it, and Task.WhenAll?#
Short answer: Awaiting a faulted Task rethrows the first exception with its original stack trace preserved through ExceptionDispatchInfo; calling .Result or .Wait() instead always wraps the failure in an AggregateException. Task.WhenAll captures every child exception, but awaiting the combined task still only rethrows the first one — you must inspect the task's Exception property to see the rest.
var t1 = Task.Run(() => throw new InvalidOperationException("first failure"));
var t2 = Task.Run(() => throw new InvalidOperationException("second failure"));
var all = Task.WhenAll(t1, t2);
try
{
await all; // rethrows only one of the two exceptions
}
catch (InvalidOperationException)
{
var everyFailure = all.Exception?.InnerExceptions; // both are here
foreach (var ex in everyFailure ?? [])
{
Log(ex);
}
}A related subtlety: if a task faults and nothing ever observes it, the process does not crash by default (that behavior changed years ago), but TaskScheduler.UnobservedTaskException still fires for diagnostics. Relying on that event for correctness is fragile — explicitly observe every task you create, especially fire-and-forget ones.
What interviewers look for: A precise distinction between the await-unwraps-first-exception behavior and the .Wait()/.Result aggregate-wrapping behavior, plus the habit of inspecting InnerExceptions after a multi-task operation so a bulk job reports every failure, not just one.
Common mistakes: Catching AggregateException around an await expression out of habit carried over from .Result/.Wait() code, where it will never actually be thrown; treating a canceled task's TaskCanceledException the same as a genuine failure instead of expected control flow.
Follow-up questions:
- What task status results if the
CancellationTokenpassed toTask.Runfires before the delegate starts running? - How would you aggregate and report every failure from a
Parallel.ForEachAsyncbatch, not just the first?
Q7 How does ExecutionContext flow across await points, and how does AsyncLocal fit in?#
Short answer: ExecutionContext is the ambient state the CLR flows across asynchronous continuations — most visibly, AsyncLocal<T> values — while SynchronizationContext only decides which thread a continuation runs on. AsyncLocal<T> is the async-aware replacement for [ThreadStatic]: its value automatically flows to child tasks and continuations through ExecutionContext capture and restore, even as the physical thread changes underneath it.
private static readonly AsyncLocal<string> CorrelationId = new();
async Task ProcessOrderAsync(string orderId)
{
CorrelationId.Value = orderId;
await StepOneAsync(); // still sees CorrelationId.Value, even on a new pool thread
await StepTwoAsync(); // same — the value flowed with the logical call, not the thread
}The flow is copy-on-write and one-directional down the call tree: a value set inside a forked branch (for example, inside an unawaited Task.Run) does not propagate back to the caller once that branch diverges. Internally, the await machinery calls ExecutionContext.Capture()/Run() on every continuation unless the awaiter used UnsafeOnCompleted, which deliberately skips that capture for performance on paths the runtime fully controls.
What interviewers look for: A crisp separation of ExecutionContext (ambient data flow) from SynchronizationContext (thread/scheduling), and awareness that logging and tracing infrastructure — correlation IDs, Activity.Current in OpenTelemetry — depend on AsyncLocal<T> specifically because thread-local storage breaks the moment a continuation resumes on a different pool thread.
Common mistakes: Treating AsyncLocal<T> like a shared mutable global across concurrent branches; assuming context flow is free — capturing and restoring ExecutionContext has a real, measurable cost on hot paths with many short-lived awaits.
Follow-up questions:
- How does
Activity.Currentstay attached across anawaitboundary using this mechanism? - Why might a library deliberately suppress
ExecutionContextflow withExecutionContext.SuppressFlow()?
Q8 What overhead does async/await actually add compared to synchronous code, and when does it matter?#
Short answer: Every await that truly suspends allocates a heap-boxed state machine, a continuation delegate, and — for Task<T>-returning methods — typically a Task object, plus the CPU cost of ExecutionContext capture and any scheduler marshaling; this overhead is usually noise next to I/O latency, but it matters on hot, frequently-synchronous paths, which is exactly why ValueTask<T> exists.
// Allocates a Task<int> even when the cache hits and nothing is awaited.
public Task<int> GetCachedAsync(string key) =>
_cache.TryGetValue(key, out var value) ? Task.FromResult(value) : LoadAndCacheAsync(key);
// ValueTask<int> avoids that allocation on the common, synchronous path.
public ValueTask<int> GetCachedAsyncFast(string key) =>
_cache.TryGetValue(key, out var value) ? new ValueTask<int>(value) : new ValueTask<int>(LoadAndCacheAsync(key));ValueTask<T> comes with real constraints: don't await it more than once, don't call .Result without checking IsCompletedSuccessfully, and don't hold onto one after it's been consumed. A candidate who reaches for ValueTask<T> everywhere without knowing these rules usually introduces subtle bugs for a marginal win. See High-Performance .NET: Techniques That Actually Matter for the broader set of trade-offs here.
What interviewers look for: A benchmark-aware answer instead of "async is slow" dogma — the insight that at scale, thread-pool starvation and unnecessary context marshaling usually dominate over the state-machine allocation itself, and that ValueTask<T> is a targeted fix for a specific, measured hot path, not a default choice.
Common mistakes: Applying ValueTask<T> to methods that are always genuinely asynchronous (no allocation benefit, only extra consumption rules to violate); avoiding async/await in ordinary CRUD code "for performance" where the difference is unmeasurable.
Follow-up questions:
- When could switching to
ValueTask<T>make allocations worse rather than better? - What does
IValueTaskSource<T>let you do that a plainValueTask<T>wrapping aTask<T>cannot?
Q9 How do try/finally, using and similar constructs behave across await points inside a state machine?#
Short answer: The compiler fully supports try/catch/finally and using (including await using) spanning await boundaries; MoveNext tracks which logical region execution is in through its state numbering, so cleanup code runs exactly where you'd expect even though the method physically suspended and resumed, possibly on a different thread.
async Task ProcessAsync(Stream source, Stream destination, CancellationToken ct)
{
await using var reader = new StreamReader(source);
try
{
var content = await reader.ReadToEndAsync(ct);
await destination.WriteAsync(Encoding.UTF8.GetBytes(content), ct);
}
finally
{
// Runs whether the awaits above succeeded, threw, or were canceled —
// even though MoveNext may resume this block on a different pool thread.
await destination.FlushAsync(ct);
}
}One deliberate exception: the compiler forbids await inside a lock block (error CS1996), because a Monitor-based lock is thread-affine — releasing it from a different thread than the one that acquired it would be unsafe, and suspension means you can't guarantee which thread resumes the block. That restriction is exactly why SemaphoreSlim.WaitAsync exists as the async-safe alternative, covered in depth in Thread Synchronization Interview Questions.
What interviewers look for: Confidence that async doesn't break structured control flow or RAII-style cleanup, plus a clear explanation of why lock and await don't mix — thread affinity, not an arbitrary compiler limitation.
Common mistakes: Trying to await inside lock { } and not knowing why it fails to compile; using a synchronous Dispose() on a resource with meaningful async cleanup instead of await using with IAsyncDisposable.
Follow-up questions:
- What would you use instead of
lockto protect an async critical section? - Was
awaitalways legal insidecatch/finallyblocks in C#, or did that change?
Q10 How would you implement a custom awaitable, and why would you do that instead of just returning a Task?#
Short answer: You write a custom awaitable when await needs to do something the built-in Task-based awaiters don't — switch execution onto a specific context without allocating a Task, wrap a non-Task async primitive, or avoid an allocation entirely on a very hot path — by exposing a GetAwaiter() that returns a type implementing IsCompleted, GetResult(), and OnCompleted/UnsafeOnCompleted.
public readonly struct ThreadPoolSwitch : ICriticalNotifyCompletion
{
public bool IsCompleted => Thread.CurrentThread.IsThreadPoolThread;
public void GetResult() { }
public void OnCompleted(Action continuation) => ThreadPool.QueueUserWorkItem(_ => continuation());
public void UnsafeOnCompleted(Action continuation) => ThreadPool.UnsafeQueueUserWorkItem(_ => continuation(), false);
public ThreadPoolSwitch GetAwaiter() => this;
}
// Usage: `await new ThreadPoolSwitch();` guarantees the rest of the method
// runs on a pool thread without going through SynchronizationContext.Post.The production-grade version of this idea is IValueTaskSource/IValueTaskSource<T>, which lets a ValueTask<T> be backed by a reusable, pooled object instead of a fresh Task per call. This is how System.IO.Pipelines and Kestrel's socket layer avoid a Task allocation on every read in a high-throughput server — advanced territory, but bringing it up unprompted is a strong signal of depth for an architect-level interview.
What interviewers look for: Evidence the candidate has gone below the Task abstraction at least once — building infrastructure, not just consuming async APIs — and can explain the trade-off between a simple custom awaiter and the added complexity of a pooled IValueTaskSource.
Common mistakes: Reaching for a custom awaiter in ordinary application code where a plain Task or ValueTask would do; forgetting ICriticalNotifyCompletion/UnsafeOnCompleted on a performance-sensitive awaiter, which forces the runtime back onto the slower context-flowing path.
Follow-up questions:
- What's the practical difference between
OnCompletedandUnsafeOnCompleted, and why does the compiler-generated state machine prefer the latter when it's available? - When would you reach for
IValueTaskSource<T>over a simple custom awaiter?
Quick-Fire Round#
| Question | Answer |
|---|---|
Does await block the calling thread? | No — it returns control to the caller and resumes via a continuation. |
What builder type backs an async Task<T> method? | AsyncTaskMethodBuilder<T>. |
Is ConfigureAwait(false) per-method or per-await? | Per-await — it only affects that specific continuation. |
Does Task.Run make code "more async"? | No — it offloads CPU work to the pool; async/await is about not blocking on I/O. |
Can you await inside a lock block? | No — CS1996; use SemaphoreSlim.WaitAsync instead. |
| What status does a canceled task end up in? | TaskStatus.Canceled, distinct from Faulted. |
| Does ASP.NET Core install a SynchronizationContext? | No — removed by design for throughput. |
What happens to an unhandled exception in async void? | Rethrown on the captured context, typically crashing the process. |
Which .NET version added ConfigureAwaitOptions? | .NET 8. |
| Is a state machine always heap-allocated? | No — it starts as a struct and is only boxed if it actually suspends. |
How to Prepare#
- Be able to sketch, from memory, the class an
async Task<T>method compiles into, including the state field, hoisted locals and the builder. - Explain
ConfigureAwaitwithout reciting "always use false in libraries" as a slogan — know the mechanism and the fourConfigureAwaitOptionsflags. - Know precisely how
await,.Resultand.Wait()differ in surfacing exceptions, and howTask.WhenAllchanges the picture. - Contrast
ExecutionContextandSynchronizationContextin one sentence each, then give an example where only one of them matters. - Review
IValueTaskSourceconceptually, even if you've never written one — architects are expected to know it exists and why. - Rehearse the
async voidfailure story end to end: what breaks, why, and what you'd use instead in a UI handler versus a background trigger.