async and await let .NET code wait for I/O without holding a thread idle. While a network or database operation is in flight, the worker can return to the pool and run something else. UI applications stay responsive, and servers can keep many requests in progress without assigning a thread to every wait.
The useful mental model is simple: async does not mean “run on another thread.” During a true asynchronous I/O wait, no thread is executing the method. Completion makes its continuation runnable later.
How the State Machine Works
The C# compiler lowers an async method into a state-machine type. For ordinary async Task methods, Roslyn commonly emits a struct; async iterators use a reference type that also serves enumerable and enumerator roles. Each suspension point becomes a state transition. At compile time, this code:
public async Task<string> FetchAsync(string url)
{
var response = await _http.GetAsync(url);
return await response.Content.ReadAsStringAsync();
}The compiler emits a state machine with fields for local state such as url and response. Its MoveNext() method advances the method until the next incomplete await. Completion schedules another call to MoveNext().
What happens at each await:
- The method runs synchronously until it hits an incomplete awaitable.
- The awaiter reports whether the operation has already completed. If it has, execution continues without yielding.
- If not done, a continuation is registered on the awaitable and the method returns an incomplete
Taskto its caller. - When the I/O completes, the continuation fires and
MoveNext()resumes from the saved state.
This is why await differs from Task.Result and Task.Wait(): those block the current thread, while await releases it.
Allocation cost. For an ordinary async Task method, the generated state machine is commonly a value type. When the method suspends, its state must survive after the current stack frame returns, so the builder stores it for later continuation. A method that completes synchronously can avoid that suspension path. ValueTask<T> can reduce allocations when synchronous completion is common, but it adds consumption rules and should be introduced only after measurement. Task remains the clear default.
ConfigureAwait
By default, await asks to resume through the current SynchronizationContext, or through a non-default current TaskScheduler. A UI application uses that behavior to return to its UI thread. ASP.NET Core normally has no custom synchronization context, so there is usually nothing to capture there.
ConfigureAwait(false) says the continuation does not require resumption through the captured context or scheduler. It does not promise a thread switch: an already-completed await may continue synchronously, and another continuation may run on the thread that completes the operation.
// Library code — no UI or request context needed
var data = await _repo.GetAsync(id).ConfigureAwait(false);General-purpose library code often uses ConfigureAwait(false) because it should not depend on a caller’s scheduling context. Application code should make the choice from its actual host: UI code may need the captured context, while ASP.NET Core code usually gains little from spelling out false. This option does not repair sync-over-async by itself. The durable fix is to keep the call chain asynchronous.
ExecutionContext Vs SynchronizationContext
These mechanisms carry different things across an await:
SynchronizationContextinfluences where the continuation runs.ConfigureAwait(false)opts out of capturing it.ExecutionContextcarries ambient state such asAsyncLocal<T>values. It flows across normal awaits even whenConfigureAwait(false)is used. Suppressing that flow is a separate, rare operation.
ConfigureAwaitOptions (.NET 8)
.NET 8 added ConfigureAwait(ConfigureAwaitOptions) for cases that need more than the Boolean choice:
ContinueOnCapturedContext— the equivalent of the oldtrue.- For a non-generic
Task,SuppressThrowingobserves completion without propagating the task’s fault or cancellation. It is deliberately narrow and should not become a way to hide failures. ForceYieldingschedules the continuation even when the task has already completed, which can break synchronous reentrancy.
Example
public async Task<OrderDto?> LoadOrderAsync(
int id,
CancellationToken cancellationToken)
{
using var response = await _httpClient.GetAsync(
$"orders/{id}",
cancellationToken);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<OrderDto>(
cancellationToken: cancellationToken);
}The method does not hold a thread while waiting on network I/O. The continuation runs only when the response is available.
Async Streams (IAsyncEnumerable)
For data that arrives incrementally, IAsyncEnumerable<T> lets the producer yield values asynchronously and the consumer pull them with await foreach. A page or message can arrive after an asynchronous wait without materializing the whole sequence.
public async IAsyncEnumerable<Order> StreamOrdersAsync(
[EnumeratorCancellation] CancellationToken ct = default)
{
string? cursor = null;
do
{
var page = await _client.GetPageAsync(cursor, ct);
foreach (var order in page.Items)
yield return order;
cursor = page.NextCursor;
} while (cursor is not null);
}
// Consumer — pulls one item at a time, never materializing the full set
await foreach (var order in StreamOrdersAsync(ct).WithCancellation(ct).ConfigureAwait(false))
{
Process(order);
}[EnumeratorCancellation] connects a token supplied by .WithCancellation(ct) to the iterator parameter. Resources with asynchronous cleanup use IAsyncDisposable and await using.
Pitfalls
Sync-over-async deadlock
Calling .Result or .Wait() under a single-threaded SynchronizationContext can deadlock. The caller occupies the context thread while the continuation waits to get back onto it.
// DEADLOCK in UI or legacy ASP.NET — never do this
var result = GetDataAsync().Result;Fix the boundary that blocks and keep the call chain asynchronous. Library code that does not need the caller’s context can also use ConfigureAwait(false), but relying on every transitive await to do so is brittle.
Fire-and-forget swallows exceptions
Calling an async method without awaiting it drops the caller’s handle to completion. A later fault sits on the returned Task with no local code to observe or report it.
// Exception is lost — the task is never observed
_ = SendEmailAsync(user);Await work that belongs to the current operation. Work that must outlive it belongs in an owned background queue where failures and shutdown are observed.
Async void
async void methods cannot be awaited and their exceptions cannot be caught by callers. They exist only for event handlers.
// Caller cannot catch this exception
public async void OnButtonClick(object sender, EventArgs e) { ... }Fix: use async Task everywhere except event handlers.
Unnecessary Task.Run wrapping
Wrapping already-async I/O in Task.Run wastes a thread pool thread for no benefit.
// Pointless — GetAsync is already async
var result = await Task.Run(() => _http.GetAsync(url));Fix: await the async method directly.
Awaiting Task.WhenAll throws one contained exception
A faulted WhenAll task records every failure in its Exception property, but await propagates one contained exception rather than throwing the surrounding AggregateException. Keep the combined task and inspect task.Exception when every failure matters.
Questions
What is the difference between asynchrony and multithreading?
Asynchrony is about not blocking while work is waiting. During asynchronous I/O, the method can pause without keeping a worker thread idle, then continue later, possibly on the same thread. Multithreading is about executing work on multiple threads at the same time, usually for CPU-bound work. An application can be asynchronous while using only one thread, but CPU-bound work still needs a thread to execute it.
What is the difference between
awaitand usingTask.Result?Both return the task’s result, but they wait differently. If the task is not complete,
awaitpauses the method without blocking the current thread. The method continues when the task finishes, using the capturedSynchronizationContextwhen one exists.Task.Resultblocks the current thread until the task completes. This can cause a deadlock when the continuation needs to return to that same thread. It also wraps errors inAggregateException, whileawaitthrows the original exception.
When is
ConfigureAwait(false)appropriate?It is mainly useful in reusable library code when the continuation does not need the caller’s scheduling context. The continuation no longer has to return through a captured
SynchronizationContextor non-defaultTaskScheduler. UI code usually keeps context capture when it needs to update controls. ASP.NET Core normally has no customSynchronizationContext, so usingConfigureAwait(false)there usually changes little, andHttpContextdoes not depend on that context.
Why can asynchronous I/O improve server scalability without using extra threads?
While I/O is pending, the asynchronous operation pauses and returns its worker thread to the pool. That thread can process another request instead of sitting idle. A server with 100 threads can therefore keep thousands of I/O-bound requests in progress, as long as those threads are released during each wait. This improves concurrency for I/O-bound work; it does not make CPU-bound work require fewer threads.
When is
Task.Runuseful in async code?
Task.Runis useful for CPU-bound work that should run on a thread-pool thread, for example to keep a UI thread responsive. It should not wrap an I/O API that is already asynchronous. That adds another scheduling step without making the I/O finish sooner.