Async/await in C# lets you write non-blocking code that reads like sequential code, but the simplicity of the syntax hides a surprisingly intricate machine. This deep dive is for experienced .NET developers who already use async and await daily and want to understand what the compiler generates, where continuations run, how exceptions and cancellation flow, and which patterns cause deadlocks or thread-pool starvation. Along the way it covers ConfigureAwaitOptions, ValueTask, IAsyncEnumerable<T>, Task.WhenEach and the runtime-async work in .NET 11.

What Is Async/Await in C#?#

Async/await is the language support for the Task-based Asynchronous Pattern (TAP). A Task or Task<T> represents an operation that may complete in the future; it is a promise, not a thread. When you await an incomplete task, the method returns to its caller and the rest of the method is registered as a continuation that runs when the task completes. No thread sits blocked while a network request or disk read is in flight, which is why a web server can handle thousands of concurrent requests with a small thread pool.

Two mental corrections prevent most misunderstandings. First, async does not make code run on a background thread; everything before the first incomplete await runs synchronously on the caller's thread. Second, await is not a blocking wait. It is a suspension point where the compiler splits your method in two.

How Async/Await Works: The Compiler-Generated State Machine#

When Roslyn compiles an async method, it turns the body into a state machine type that implements IAsyncStateMachine, and it replaces the original method with a small stub. Consider this method:

C#
public async Task<int> GetOrderCountAsync(HttpClient http, CancellationToken ct)
{
    string json = await http.GetStringAsync("/api/orders/count", ct);
    return int.Parse(json);
}

The generated code is roughly equivalent to the following simplified sketch:

C#
public Task<int> GetOrderCountAsync(HttpClient http, CancellationToken ct)
{
    var sm = new GetOrderCountStateMachine
    {
        Builder = AsyncTaskMethodBuilder<int>.Create(),
        Http = http,
        Ct = ct,
        State = -1,
    };
    sm.Builder.Start(ref sm);   // runs MoveNext synchronously until the first real suspension
    return sm.Builder.Task;
}

struct GetOrderCountStateMachine : IAsyncStateMachine
{
    public int State;
    public AsyncTaskMethodBuilder<int> Builder;
    public HttpClient Http;
    public CancellationToken Ct;
    private TaskAwaiter<string> _awaiter;

    public void MoveNext()
    {
        try
        {
            if (State != 0)
            {
                _awaiter = Http.GetStringAsync("/api/orders/count", Ct).GetAwaiter();
                if (!_awaiter.IsCompleted)
                {
                    State = 0;
                    Builder.AwaitUnsafeOnCompleted(ref _awaiter, ref this);   // suspend
                    return;
                }
            }

            State = -1;
            string json = _awaiter.GetResult();   // returns the value or rethrows
            Builder.SetResult(int.Parse(json));
        }
        catch (Exception ex)
        {
            State = -2;
            Builder.SetException(ex);
        }
    }

    public void SetStateMachine(IAsyncStateMachine stateMachine) =>
        Builder.SetStateMachine(stateMachine);
}

Three pieces cooperate. The state machine holds locals and parameters as fields plus an integer state that records which await to resume after. The builder (AsyncTaskMethodBuilder<T>, or AsyncValueTaskMethodBuilder<T> for ValueTask) creates the returned task and completes it with SetResult or SetException. The awaiter comes from the awaitable's GetAwaiter() method and exposes IsCompleted, OnCompleted or UnsafeOnCompleted, and GetResult. Any type with that shape can be awaited, which is why await works on Task, ValueTask, Task.Yield() and custom awaitables.

The IsCompleted check is the synchronous fast path: if the awaited operation already finished, the method continues immediately without scheduling anything. In Release builds the state machine is a struct, so a method that never suspends allocates nothing beyond its result task. Even that allocation can disappear, because a method that completes synchronously gets a cached task for common results, such as a completed Task, true, false, or Task<int> values from -1 to 8. On the first real suspension, the runtime copies the struct into a heap box that is itself the returned Task<T>, so the task and the state machine share one allocation.

.NET 11 adds a second implementation called runtime async, in which the runtime rather than compiler-generated state machines manages suspension and resumption. The goals are lower overhead and cleaner stack traces. In the .NET 11 previews the runtime libraries themselves were rebuilt with it, while your own code opts in through the runtime-async=on compiler feature switch. The semantics described in this guide do not change; only the machinery underneath does.

Getting Started: A Correct Async Method#

A well-behaved async API accepts a CancellationToken, awaits instead of blocking, disposes resources with using, and lets exceptions propagate through the task. Here it is in an ASP.NET Core minimal API, where the framework binds CancellationToken to the request-aborted token:

C#
using System.Net;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient<WeatherClient>(
    c => c.BaseAddress = new("https://weather.contoso.com/"));
var app = builder.Build();

app.MapGet("/forecast/{city}", async (string city, WeatherClient client, CancellationToken ct) =>
    await client.GetForecastAsync(city, ct) is { } forecast
        ? Results.Ok(forecast)
        : Results.NotFound());

app.Run();

public sealed class WeatherClient(HttpClient http)
{
    public async Task<Forecast?> GetForecastAsync(string city, CancellationToken ct)
    {
        using var response = await http.GetAsync($"forecast/{Uri.EscapeDataString(city)}", ct);
        if (response.StatusCode == HttpStatusCode.NotFound) return null;

        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<Forecast>(ct);
    }
}

public sealed record Forecast(string City, double TemperatureC, string Summary);

SynchronizationContext, TaskScheduler and Where Continuations Run#

When you await a task with the default settings, the awaiter captures the "current context" and resumes there. The runtime's logic is precise. If SynchronizationContext.Current is set to anything other than the base SynchronizationContext type, the continuation is posted to it. Otherwise, if the current TaskScheduler is not the default scheduler, the continuation is queued to that scheduler. Otherwise, the continuation runs on the thread pool, often inline on the thread that completed the task.

That rule explains platform differences. WinForms, WPF and .NET MAUI install a single-threaded SynchronizationContext, so code after await in a button handler runs on the UI thread and can touch controls. ASP.NET Core has no SynchronizationContext, so continuations run on thread-pool threads. Classic ASP.NET on .NET Framework used a request context, which is where many deadlock stories originate. Console apps have no context unless you install one.

ExecutionContext is different from SynchronizationContext. It carries ambient state such as AsyncLocal<T> values, culture and activity tracing, and it flows across every await regardless of ConfigureAwait. That is how logging scopes and Activity.Current survive asynchronous hops.

ConfigureAwait(false) and ConfigureAwaitOptions#

ConfigureAwait(false) tells the awaiter not to capture the context, so the continuation runs wherever the task completed. In reusable libraries this avoids unnecessary thread hops and protects callers who block on your code from context deadlocks. In ASP.NET Core application code it has no practical effect because there is no context to capture. In UI code, use it only when nothing after the await touches UI elements.

.NET 8 added an overload that takes the ConfigureAwaitOptions flags enum. None is equivalent to ConfigureAwait(false), ContinueOnCapturedContext to ConfigureAwait(true), and two new behaviors are available:

C#
// Library code: avoid resuming on the caller's context.
var rows = await ParseAsync(input, ct).ConfigureAwait(false);

// Wait for completion without throwing, for example while shutting down.
await backgroundWork.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
if (backgroundWork.IsFaulted)
    logger.LogWarning(backgroundWork.Exception, "Background work failed during shutdown");

// Always yield, even if the task already completed; resume on the thread pool.
await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding);

SuppressThrowing is supported only on the non-generic Task, because a Task<T> would otherwise produce an invalid result; cast to Task if you need it for a generic task. ForceYielding without ContinueOnCapturedContext behaves like Task.Yield() combined with ConfigureAwait(false), which is useful for pushing work off the current thread.

Exceptions, AggregateException and Task.WhenAll#

A faulted task stores its exceptions in Task.Exception, an AggregateException. The three ways of observing it differ. await rethrows the first inner exception with its original stack trace, so async code reads like synchronous code. Task.Wait() and Task.Result throw the AggregateException wrapper. GetAwaiter().GetResult() blocks but rethrows the original exception.

The first-exception rule matters with Task.WhenAll. If several operations fail, await surfaces only one of them, but the combined task keeps them all:

C#
Task<Quote>[] quoteTasks = [.. suppliers.Select(s => s.GetQuoteAsync(sku, ct))];
Task<Quote[]> all = Task.WhenAll(quoteTasks);

try
{
    Quote[] quotes = await all;
    return quotes.MinBy(q => q.Price);
}
catch (Exception) when (all.Exception is { } aggregate)
{
    foreach (var error in aggregate.InnerExceptions)
        logger.LogError(error, "Supplier quote failed for {Sku}", sku);
    throw;
}

The collection expression materializes the tasks immediately. A lazy LINQ query passed around and enumerated twice would start every operation twice. The exception filter matches only a faulted task; a canceled WhenAll has no Exception, so its OperationCanceledException propagates untouched. Exceptions on tasks that nobody observes raise the TaskScheduler.UnobservedTaskException event when the task is finalized, but they do not crash the process.

async void and Fire-and-Forget#

An async void method has no task, so callers cannot await it or catch its exceptions. An exception escaping it is raised on the SynchronizationContext that was current when the method started, or on the thread pool if there was none, and on the thread pool an unhandled exception terminates the process. Microsoft's guidance is to use async void only for event handlers, and to wrap their bodies in try/catch.

Fire-and-forget work has the same problem with extra risks: nothing observes failures, and nothing keeps request-scoped services alive. In ASP.NET Core, hand the work to a hosted service through a channel instead of calling an un-awaited method from a controller; the background services guide shows the pattern.

ValueTask and ValueTask of T#

ValueTask<T> is a struct that wraps either a result, a Task<T>, or an IValueTaskSource<T>. It pays off in hot paths that usually complete synchronously, such as cache hits and buffered stream reads, because the synchronous path allocates nothing:

C#
public sealed class PriceCache(IPriceApi api)
{
    private readonly ConcurrentDictionary<string, decimal> _prices = new();

    public ValueTask<decimal> GetPriceAsync(string sku, CancellationToken ct) =>
        _prices.TryGetValue(sku, out var price)
            ? ValueTask.FromResult(price)                   // no allocation on a cache hit
            : new ValueTask<decimal>(LoadAsync(sku, ct));   // falls back to a Task

    private async Task<decimal> LoadAsync(string sku, CancellationToken ct)
    {
        var price = await api.FetchPriceAsync(sku, ct);
        _prices[sku] = price;
        return price;
    }
}

The cost is a stricter contract. The documentation lists operations you must never perform on a ValueTask<T>: awaiting it more than once, calling AsTask() more than once, reading Result before it completes, or mixing these techniques. The instance may be backed by a pooled object that is reused after the first consumption. If a caller needs to await twice or store the operation, call AsTask() once. It is also a larger struct, so returning it everywhere makes state machines bigger. Default to Task; use ValueTask where profiling shows allocation pressure. For methods that do suspend frequently, the PoolingAsyncValueTaskMethodBuilder applied with [AsyncMethodBuilder] pools the state machine boxes, trading memory for fewer allocations.

IAsyncEnumerable and Async Streams#

Async iterators, introduced in C# 8, combine yield return with await to produce IAsyncEnumerable<T>. They are the right shape for paged APIs, database readers and message streams where items arrive over time:

C#
public async IAsyncEnumerable<Order> GetOrdersAsync(
    [EnumeratorCancellation] CancellationToken ct = default)
{
    string? continuation = null;
    do
    {
        OrderPage page = await api.GetOrderPageAsync(continuation, ct);
        foreach (var order in page.Items)
            yield return order;

        continuation = page.ContinuationToken;
    }
    while (continuation is not null);
}

// Consumer: the token passed to WithCancellation reaches the iterator's ct parameter.
await foreach (var order in client.GetOrdersAsync().WithCancellation(ct).ConfigureAwait(false))
{
    await ProcessAsync(order, ct);
}

// .NET 10 includes LINQ for async streams (System.Linq.AsyncEnumerable).
List<Order> bigOrders = await client.GetOrdersAsync(ct)
    .Where(o => o.Total > 1_000m)
    .Take(50)
    .ToListAsync(ct);

The [EnumeratorCancellation] attribute makes the compiler combine a token passed to the method with one supplied through WithCancellation, so both styles work. Before .NET 10, LINQ operators for async streams came from the community-maintained System.Linq.Async package; projects upgrading to .NET 10 should remove that reference (or move to its version 7) to avoid ambiguity errors.

Cancellation and Timeouts#

Cancellation in .NET is cooperative: a CancellationTokenSource signals, and code that receives the CancellationToken checks it or passes it on. Accept a token in every public async method, pass it to every awaited call, and treat OperationCanceledException as a normal outcome rather than an error. The common production pattern links the caller's token with a per-operation timeout:

C#
public async Task<Invoice> GetInvoiceAsync(string id, CancellationToken requestAborted)
{
    using var cts = CancellationTokenSource.CreateLinkedTokenSource(requestAborted);
    cts.CancelAfter(TimeSpan.FromSeconds(5));

    try
    {
        return await repository.LoadAsync(id, cts.Token);
    }
    catch (OperationCanceledException) when (!requestAborted.IsCancellationRequested)
    {
        throw new TimeoutException($"Loading invoice {id} took longer than 5 seconds.");
    }
}

// .NET 6+: stop waiting for an operation that ignores tokens (it keeps running).
Report report = await legacy.BuildReportAsync().WaitAsync(TimeSpan.FromSeconds(2), ct);

The exception filter distinguishes a timeout from a client disconnect. .NET 8 added CancellationTokenSource.CancelAsync(), which runs registered callbacks without blocking the caller, and TimeProvider, whose overloads of Task.Delay, WaitAsync and the CancellationTokenSource constructor let tests drive timeouts with a fake clock. Remember that WaitAsync abandons the wait, not the work. See the cancellation interview questions for graceful shutdown patterns.

Coordinating Tasks: WhenAll, WhenAny and WhenEach#

Task.WhenAll waits for everything and Task.WhenAny returns the first task to finish, which is useful for timeouts and hedged requests. To handle results in completion order, older code called WhenAny in a loop and removed finished tasks, which is quadratic. .NET 9 added Task.WhenEach, which returns an IAsyncEnumerable of tasks as they complete. For bounded concurrency over a large input, Parallel.ForEachAsync (.NET 6) limits the number of simultaneous operations:

C#
// .NET 9+: react to each result as soon as it arrives.
await foreach (Task<Quote> completed in Task.WhenEach(quoteTasks))
{
    try
    {
        Quote quote = await completed;   // already complete: no suspension
        logger.LogInformation("{Supplier} quoted {Price}", quote.Supplier, quote.Price);
    }
    catch (HttpRequestException ex)
    {
        logger.LogWarning(ex, "A supplier failed");
    }
}

// .NET 6+: at most eight refreshes in flight, with cancellation.
await Parallel.ForEachAsync(
    skus,
    new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = ct },
    async (sku, token) => await catalog.RefreshAsync(sku, token));

Why Blocking on Async Code Deadlocks#

The classic async deadlock needs three ingredients: a single-threaded SynchronizationContext, a caller that blocks on a task, and an await inside that task that wants to resume on the captured context. A WinForms or WPF event handler provides all three:

C#
// The deadlock: the UI thread blocks, and the continuation needs that same thread.
private void RefreshButton_Click(object sender, EventArgs e)
{
    List<Order> orders = _service.LoadOrdersAsync().Result;   // 1. UI thread blocks here
    ordersGrid.DataSource = orders;
}

public async Task<List<Order>> LoadOrdersAsync()
{
    string json = await _http.GetStringAsync("orders");   // 2. captures the UI context
    return ParseOrders(json);                            // 3. waits for the blocked UI thread
}

When the HTTP call completes, the continuation is posted to the UI thread's message queue, but that thread is stuck inside .Result, waiting for the very task the continuation would complete. Nothing ever moves again. ConfigureAwait(false) inside LoadOrdersAsync breaks this particular cycle, but only if every await in the entire call chain uses it, which is fragile. The real fix removes the blocking call:

C#
private async void RefreshButton_Click(object sender, EventArgs e)
{
    try
    {
        ordersGrid.DataSource = await _service.LoadOrdersAsync();
    }
    catch (HttpRequestException ex)
    {
        MessageBox.Show(this, ex.Message, "Refresh failed");
    }
}

On ASP.NET Core the same code does not deadlock, because there is no context to capture, but it still blocks a thread-pool thread for the full duration of the I/O. See deadlocks and race conditions for more failure modes.

Choosing an Async Return Type#

Return typeUse it whenCost and caveats
TaskAn async operation has no resultAllocates when the method suspends; completes with a cached task otherwise
Task<T>An async operation produces a resultCan be awaited many times, stored and combined freely
ValueTask<T>A hot path usually completes synchronouslyAwait exactly once; larger struct; profile before adopting
IAsyncEnumerable<T>Results arrive over timePass tokens with [EnumeratorCancellation]; consume with await foreach
async voidEvent handlers onlyCallers cannot await it or catch its exceptions

Best Practices#

  • Go async all the way. Once a call chain contains I/O, make every caller async instead of blocking in the middle.
  • Accept and forward a CancellationToken. Put it last in the parameter list and pass it to every awaited call.
  • Use ConfigureAwait(false) in libraries, not as a ritual in ASP.NET Core application code.
  • Materialize tasks before combining them. Use ToArray(), ToList() or a collection expression so each operation starts exactly once.
  • Create TaskCompletionSource with RunContinuationsAsynchronously. Otherwise awaiting code can run synchronously inside your SetResult call, holding your locks.
  • Use SemaphoreSlim.WaitAsync for async mutual exclusion. The compiler forbids await inside lock, and blocking locks around async work invite deadlocks.
  • Prefer Task and measure before switching to ValueTask.

Common Pitfalls#

  • Sync-over-async deadlocks. .Result or .Wait() on a UI thread blocks the only thread that can run the continuation, as shown above. If you truly must block, prefer GetAwaiter().GetResult() so you at least see the original exception.
  • Thread-pool starvation. In ASP.NET Core, blocking on async code does not deadlock, but each blocked request holds a pool thread. Under load the pool grows slowly, and latency climbs.
  • Task.Run around async I/O on servers. It adds a thread hop and scheduling cost without freeing anything. Use Task.Run for CPU-bound work, mostly in client apps.
  • Task.Factory.StartNew with an async lambda. It returns a Task<Task>, so awaiting it waits only for the first await. Use Task.Run, which unwraps, or call Unwrap().
  • Eliding async inside using or try blocks. Returning the task directly from a method that disposes a resource can dispose it before the operation finishes.
  • Forgetting to await. An un-awaited task swallows its exceptions; treat compiler warning CS4014 as an error.

Frequently Asked Questions#

Does async/await create new threads?#

No. An async method runs on the caller's thread until it awaits something incomplete, and its continuation runs on the captured context or a thread-pool thread. I/O operations such as sockets and files wait for operating-system completion notifications without occupying a thread. Only explicit calls such as Task.Run queue work to the thread pool.

Should I still use ConfigureAwait(false) in .NET 10?#

In general-purpose libraries, yes, because your code may run under a UI or custom SynchronizationContext. In ASP.NET Core application code it is unnecessary because there is no context to capture. In UI code, omit it whenever the code after await touches UI elements.

When should I use ValueTask instead of Task?#

Use ValueTask or ValueTask<T> for frequently called methods that usually complete synchronously, such as cache lookups or buffered reads, after profiling shows allocation pressure. Keep Task everywhere else, because it can be awaited multiple times, stored and combined without restrictions.

Why does await throw only one exception from Task.WhenAll?#

await rethrows the first exception so that async code behaves like synchronous code. The task returned by Task.WhenAll still holds all failures in its Exception.InnerExceptions collection, so keep a reference to it and inspect that collection in the catch block.

What is runtime async in .NET 11?#

Runtime async moves the implementation of async methods from compiler-generated state machines into the .NET runtime, which aims to reduce overhead and produce clearer stack traces. The .NET 11 runtime libraries are compiled with it, while your own code opts in with the runtime-async=on compiler feature switch. The language rules and the guidance in this article are unchanged.

Summary#

  • await splits a method into a state machine; the builder produces the task, and awaiters decide whether to suspend.
  • Continuations resume on the captured SynchronizationContext or TaskScheduler unless you opt out with ConfigureAwait(false) or ConfigureAwaitOptions.
  • await rethrows the first exception; Task.Exception keeps them all.
  • Reserve async void for event handlers, and ValueTask for measured hot paths.
  • Use IAsyncEnumerable<T> for streams, pass cancellation tokens everywhere, and use Task.WhenEach or Parallel.ForEachAsync to coordinate many operations.
  • Never block on async code; it deadlocks UI apps and starves thread pools on servers.

Further Reading#