Async and Await

This section documents C# 14 on .NET 10 (LTS), as published at learn.microsoft.com/dotnet/csharp, which is the reference these pages are written and verified against. Features introduced by C# 15 / .NET 11 are still in preview and are always flagged as such — never presented as baseline.

This content was generated with the assistance of AI and should be verified against learn.microsoft.com before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

Asynchrony is about not blocking a thread while waiting. A web request, a database query and a file read all spend almost all of their time waiting for something outside the process; a thread parked on that wait is a thread that cannot serve anyone else. async and await let a method give its thread back while it waits and pick up where it left off when the result arrives — written in the ordinary, sequential shape of the code that blocked.

Asynchrony is not parallelism. await does not start a thread; it releases one. Running CPU-bound work on another thread is a different job, covered in Threads and Synchronization.

The Task-Based Asynchronous Pattern

The .NET convention, TAP, is simple: an asynchronous operation is a method that returns Task, Task<T> or ValueTask<T>, is named with an Async suffix, and optionally accepts a CancellationToken. The returned object represents an operation already in flight:

public sealed class PriceService(HttpClient http)
{
    public async Task<decimal> GetPriceAsync(string symbol, CancellationToken cancellationToken = default)
    {
        using HttpResponseMessage response =
            await http.GetAsync($"/prices/{symbol}", cancellationToken).ConfigureAwait(false);

        response.EnsureSuccessStatusCode();

        string body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
        return decimal.Parse(body, System.Globalization.CultureInfo.InvariantCulture);
    }
}

async is an implementation detail of a method, not part of its signature as far as callers are concerned: what callers see is that it returns a Task<decimal>. An interface declares Task<decimal> GetPriceAsync(…); whether the implementation uses async is up to it.

What await Actually Does

Given await expression, the compiler:

  1. Evaluates the expression to an awaitable — anything with a GetAwaiter() method whose result implements INotifyCompletion and exposes IsCompleted and GetResult().

  2. Checks IsCompleted. If the operation has already finished, execution simply continues — no suspension, no allocation, no thread switch.

  3. Otherwise, it registers the rest of the method as a continuation, returns to the caller, and the thread is free.

  4. When the operation completes, the continuation is scheduled, the method resumes at the await, and GetResult() either yields the value or rethrows the captured exception.

sequenceDiagram autonumber participant Caller participant Method as GetPriceAsync participant IO as I/O (network) participant Pool as Thread pool Caller->>Method: call GetPriceAsync(symbol) Method->>IO: start the request Note over Method: await -- the operation has not completed Method-->>Caller: return an incomplete Task Note over Caller: the calling thread is free to do other work IO-->>Pool: response arrives, continuation is scheduled Pool->>Method: resume after the await Method->>Method: parse the body Method-->>Caller: complete the Task with the result

The crucial point is step 3: the method returns to its caller at the first incomplete await, but it has not finished. It has handed back a task that will complete later.

Task, Task<T> and ValueTask<T>

Task is a reference type representing an operation that will complete, fail or be cancelled. Task<T> adds a result. Both are hot — by convention, a task returned from a method has already been started; you never call Start() on one.

ValueTask<T> is a struct that wraps either an already-known result or a Task<T>. Its purpose is to avoid allocating a task in the common case where the operation completes synchronously — a cached value, a buffered read:

public sealed class CachingReader
{
    private readonly Dictionary<string, string> _cache = new();

    public ValueTask<string> ReadAsync(string key, CancellationToken cancellationToken = default)
    {
        if (_cache.TryGetValue(key, out string? cached))
        {
            return new ValueTask<string>(cached);       // no allocation at all
        }

        return new ValueTask<string>(LoadAsync(key, cancellationToken));
    }

    private async Task<string> LoadAsync(string key, CancellationToken cancellationToken)
    {
        await Task.Delay(10, cancellationToken).ConfigureAwait(false);
        string value = key.ToUpperInvariant();
        _cache[key] = value;
        return value;
    }
}

ValueTask<T> comes with restrictions that Task<T> does not have. A ValueTask may be awaited only once, must not be awaited concurrently, and .Result must not be read before it completes. If you need to await a result twice, or hand it to several consumers, call .AsTask() first and use that. Use Task<T> by default; reach for ValueTask<T> on hot paths that usually complete synchronously.

Async Return Types

Return type Use it for

Task

An asynchronous operation with no result.

Task<T>

An asynchronous operation producing a T. The default choice.

ValueTask / ValueTask<T>

Hot paths that usually complete synchronously; single-await only.

void

Event handlers only. See async void.

IAsyncEnumerable<T>

An asynchronous stream of values — an async iterator. See Async Streams.

A custom task-like type

Advanced: a type marked with [AsyncMethodBuilder], such as `IAsyncEnumerable’s builder or a library’s own.

A method returning Task need not be async at all. When it only forwards, returning the inner task directly avoids an extra state machine — at the cost of losing the try/catch and using scopes an async method would give it:

public sealed class Forwarding(HttpClient http)
{
    // No state machine: the inner task is returned as-is.
    public Task<string> GetAsync(string url) => http.GetStringAsync(url);
}

The Compiler-Generated State Machine

An async method is rewritten into a struct implementing IAsyncStateMachine, with a field per local that lives across an await and an int field tracking where to resume. The method body becomes a MoveNext() switch over that state. Roughly:

// What you write
public sealed class Sketch
{
    public async Task<int> SumAsync(Task<int> first, Task<int> second)
    {
        int a = await first;
        int b = await second;
        return a + b;
    }
}

The compiler produces (conceptually) a state machine whose MoveNext runs to the first await, stores a into a field, registers a continuation, and returns; when resumed it jumps to state 1, reads a back out of the field, and proceeds. The builder (AsyncTaskMethodBuilder<int>) owns the Task<int> handed to the caller and completes it with the result or the exception.

Two consequences worth internalising:

  • The state machine is a struct. As long as the method never actually suspends, it stays on the stack and the method allocates nothing. Only the first real suspension boxes it onto the heap.

  • Awaits are sequential. await first; await second; waits for the first before starting to wait for the second. If both were started before the awaits, they overlap; if second is itself produced by a call made after the first await, they cannot.

public sealed class Sequencing(HttpClient http)
{
    // Sequential: the second request starts only after the first finishes.
    public async Task<int> SequentialAsync()
    {
        string a = await http.GetStringAsync("/a").ConfigureAwait(false);
        string b = await http.GetStringAsync("/b").ConfigureAwait(false);
        return a.Length + b.Length;
    }

    // Concurrent: both requests are in flight before either is awaited.
    public async Task<int> ConcurrentAsync()
    {
        Task<string> a = http.GetStringAsync("/a");
        Task<string> b = http.GetStringAsync("/b");

        string[] results = await Task.WhenAll(a, b).ConfigureAwait(false);
        return results[0].Length + results[1].Length;
    }
}

Synchronization Contexts and ConfigureAwait

By default, await captures the current SynchronizationContext (or TaskScheduler) and resumes the continuation on it. In a UI application that means resuming on the UI thread, which is exactly what you want when the code after the await touches controls. In a console application or ASP.NET Core there is no synchronization context, and continuations run on the thread pool.

ConfigureAwait(false) says "I do not care where I resume":

public static class ContextControl
{
    public static async Task<int> LibraryMethodAsync(Stream stream)
    {
        // Library code: never needs the caller's context, and capturing it
        // costs a context switch -- and risks a deadlock in some hosts.
        byte[] buffer = new byte[1024];
        return await stream.ReadAsync(buffer).ConfigureAwait(false);
    }
}

The guidance:

  • Library code: use ConfigureAwait(false) on every await. It is faster and it is what makes a library safe to call from a UI application.

  • Application code with a UI: omit it where you need to resume on the UI thread.

  • ASP.NET Core: there is no synchronization context, so it makes no behavioural difference; it is still harmless and some teams apply it uniformly.

NET 8 added ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing) and ForceYielding for finer control:
public static class ConfigureAwaitOptionsUsage
{
    public static async Task IgnoreFailureAsync(Task work)
    {
        // Await completion without observing an exception or cancellation.
        await work.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
    }

    public static async Task AlwaysYieldAsync(Task work)
    {
        // Force an asynchronous resumption even if `work` is already complete.
        await work.ConfigureAwait(ConfigureAwaitOptions.ForceYielding);
    }
}

Composing Tasks

public sealed class Composition(HttpClient http)
{
    // All of them, in parallel; fails if any fails, after all have completed.
    public async Task<string[]> AllAsync(IEnumerable<string> urls)
    {
        IEnumerable<Task<string>> requests = urls.Select(u => http.GetStringAsync(u));
        return await Task.WhenAll(requests).ConfigureAwait(false);
    }

    // The first to finish wins; the rest keep running unless cancelled.
    public async Task<string> FastestAsync(string primary, string mirror)
    {
        Task<string> a = http.GetStringAsync(primary);
        Task<string> b = http.GetStringAsync(mirror);

        Task<string> winner = await Task.WhenAny(a, b).ConfigureAwait(false);
        return await winner.ConfigureAwait(false);      // observe its result or exception
    }

    // .NET 9+: consume results in completion order, without a WhenAny loop.
    public async Task ProcessAsTheyFinishAsync(IEnumerable<string> urls)
    {
        List<Task<string>> requests = urls.Select(u => http.GetStringAsync(u)).ToList();

        await foreach (Task<string> finished in Task.WhenEach(requests).ConfigureAwait(false))
        {
            Console.WriteLine((await finished.ConfigureAwait(false)).Length);
        }
    }
}

Notes on Task.WhenAll:

  • It waits for every task, even after one has failed.

  • If several tasks fail, the returned task carries an AggregateException holding all of them — but await rethrows only the first. Inspect task.Exception when you need them all.

  • Task.WhenAll over Task<T> returns Task<T[]> in the same order as the input, regardless of completion order.

Task.WhenAny returns the task, not its result, so it never throws for a faulted winner — you must await the winner to observe it. Also remember that losing tasks keep running; pair WhenAny with cancellation, or with Task.WaitAsync below, when the losers matter.

Cancellation

Cancellation in .NET is cooperative. A CancellationTokenSource owns the trigger; the CancellationToken it produces is passed down the call chain, and each operation decides how to respond:

public sealed class Cancellable(HttpClient http)
{
    public async Task<string> RunAsync(string url, CancellationToken cancellationToken)
    {
        // Pass the token to every call that accepts one.
        string body = await http.GetStringAsync(url, cancellationToken).ConfigureAwait(false);

        foreach (string line in body.Split('\n'))
        {
            // In a loop of your own work, check the token explicitly.
            cancellationToken.ThrowIfCancellationRequested();
            Process(line);
        }

        return body;
    }

    private static void Process(string line) => _ = line.Length;
}

public static class CancellationDrivers
{
    public static async Task WithDeadlineAsync(Cancellable service, string url)
    {
        using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));

        try
        {
            await service.RunAsync(url, cts.Token).ConfigureAwait(false);
        }
        catch (OperationCanceledException) when (cts.IsCancellationRequested)
        {
            Console.WriteLine("timed out after five seconds");
        }
    }

    public static async Task WithLinkedTokenAsync(Cancellable service, string url, CancellationToken outer)
    {
        // Cancel when EITHER the caller cancels or the local deadline elapses.
        using var cts = CancellationTokenSource.CreateLinkedTokenSource(outer);
        cts.CancelAfter(TimeSpan.FromSeconds(2));

        await service.RunAsync(url, cts.Token).ConfigureAwait(false);
    }
}

Cancellation surfaces as OperationCanceledException (or its subclass TaskCanceledException), which is expected, not a failure: a cancelled task ends in the Canceled state, not Faulted.

Task.WaitAsync (.NET 6+) applies a timeout or a token to any task, including one whose API takes neither:

public static class Timeouts
{
    public static async Task<string> WithTimeoutAsync(Task<string> work, CancellationToken cancellationToken)
    {
        try
        {
            return await work.WaitAsync(TimeSpan.FromSeconds(3), cancellationToken).ConfigureAwait(false);
        }
        catch (TimeoutException)
        {
            return "(timed out)";
        }
    }
}

Note the caveat: WaitAsync stops waiting; it cannot stop the underlying operation, which keeps running. Only a token threaded into that operation can actually cancel it.

Exceptions in Async Code

An exception thrown inside an async method is captured and placed on the returned task; it is rethrown, with its original stack trace preserved, when the task is awaited:

public static class AsyncExceptions
{
    public static async Task<int> FailsAsync()
    {
        await Task.Yield();
        throw new InvalidOperationException("boom");
    }

    public static async Task HandleAsync()
    {
        try
        {
            await FailsAsync().ConfigureAwait(false);
        }
        catch (InvalidOperationException ex)
        {
            // Ordinary try/catch works across awaits.
            Console.WriteLine(ex.Message);
        }
    }

    public static async Task HandleManyAsync(Task[] work)
    {
        Task all = Task.WhenAll(work);

        try
        {
            await all.ConfigureAwait(false);
        }
        catch (Exception)
        {
            // `await` rethrew only the first failure; all of them are here.
            foreach (Exception inner in all.Exception!.InnerExceptions)
            {
                Console.WriteLine(inner.Message);
            }
        }
    }
}

A task that fails and is never awaited is an unobserved exception. In modern .NET it does not crash the process, but the TaskScheduler.UnobservedTaskException event fires when the task is finalized. Awaiting every task you create — or deliberately observing it — is the fix. See Exceptions and Error Handling.

Async Streams

An async iterator returns IAsyncEnumerable<T> and combines yield return with await. It is the right shape for paged APIs, streaming responses and anything producing values over time:

public sealed class Paging(HttpClient http)
{
    public async IAsyncEnumerable<string> ReadPagesAsync(
        string url,
        [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
    {
        for (int page = 1; page <= 3; page++)
        {
            string body = await http.GetStringAsync($"{url}?page={page}", cancellationToken)
                                    .ConfigureAwait(false);

            foreach (string line in body.Split('\n'))
            {
                yield return line;
            }
        }
    }
}

public static class AsyncStreamConsumer
{
    public static async Task ConsumeAsync(Paging paging, CancellationToken cancellationToken)
    {
        await foreach (string line in paging.ReadPagesAsync("/log")
                                            .WithCancellation(cancellationToken)
                                            .ConfigureAwait(false))
        {
            Console.WriteLine(line);
        }
    }
}

[EnumeratorCancellation] is what lets a token supplied at enumeration time (via WithCancellation) reach the iterator’s own parameter. Without it, the token passed to WithCancellation would be ignored by the method body.

await foreach also drives IAsyncDisposable on the enumerator, so cleanup in a finally inside the iterator runs asynchronously and correctly.

async Main

An entry point may be asynchronous. The compiler generates a synchronous Main that waits on it:

public static class EntryPoint
{
    public static async Task<int> MainAsync(string[] args)
    {
        await Task.Delay(10).ConfigureAwait(false);
        return args.Length;
    }
}

Written as a top-level program, await may simply be used at file scope. Permitted entry-point signatures are Task Main(), Task Main(string[]), Task<int> Main() and Task<int> Main(string[]).

Pitfalls

async void

An async void method returns nothing that can be awaited, so the caller cannot know when it finished and cannot catch its exceptions — an exception escapes onto the synchronization context and typically crashes the process. The only justified use is an event handler, whose signature is fixed:

public sealed class Handler
{
    // Acceptable: an event handler signature leaves no choice.
    public async void OnClicked(object? sender, EventArgs e)
    {
        try
        {
            await DoWorkAsync().ConfigureAwait(true);
        }
        catch (Exception ex)
        {
            // An async void method MUST catch its own exceptions.
            Console.WriteLine(ex.Message);
        }
    }

    private static Task DoWorkAsync() => Task.CompletedTask;
}

Everywhere else, return Task.

Sync-Over-Async

Calling .Result, .Wait() or GetAwaiter().GetResult() on an incomplete task blocks the calling thread. In a context that has a synchronization context (a UI thread, or classic ASP.NET), the continuation needs that same thread to resume — and the thread is blocked waiting for it. That is a deadlock:

public sealed class Deadlocking(HttpClient http)
{
    // Do not do this.
    public string BlockingCall() => http.GetStringAsync("/data").Result;

    // Do this: async all the way up.
    public Task<string> NonBlockingCall() => http.GetStringAsync("/data");
}

Even without a synchronization context, blocking burns a thread-pool thread and, under load, causes thread-pool starvation. The rule is "async all the way": if a method awaits, its callers await, up to the entry point or the framework.

Forgotten await

Calling an async method without awaiting it compiles (warning CS4014) and starts the operation, but the caller continues immediately — ordering is lost and failures are unobserved. Where this is genuinely intentional, say so explicitly:

public static class FireAndForget
{
    public static void Start(Func<Task> work)
    {
        // Deliberate, and the failure is still observed.
        _ = Task.Run(async () =>
        {
            try
            {
                await work().ConfigureAwait(false);
            }
            catch (Exception ex)
            {
                Console.WriteLine(ex.Message);
            }
        });
    }
}

Other Traps

  • async over CPU-bound work. async does not make code parallel. Wrap genuinely CPU-bound work in Task.Run at the call site, not inside the library method.

  • Task.Delay versus Thread.Sleep. Thread.Sleep blocks; in async code always await Task.Delay.

  • Capturing a Span<T> across an await. Not allowed — ref struct types cannot live in a state machine’s fields. Use Memory<T>.

  • Awaiting inside a lock. Not allowed either. Use SemaphoreSlim.WaitAsync for asynchronous mutual exclusion — see Threads and Synchronization.

  • ValueTask awaited twice. Undefined behaviour. Call AsTask() if it must be reused.

Practical Guidance

  • Return Task/Task<T>, name the method …Async, and accept a CancellationToken.

  • Await everything, all the way to the entry point; never block on a task.

  • Use ConfigureAwait(false) in libraries.

  • Start concurrent work before awaiting it, and compose with Task.WhenAll.

  • Pass the cancellation token down; do not invent a new one halfway.

  • Reserve async void for event handlers, and make them catch everything.

  • Reach for ValueTask<T> only when a hot path measurably needs it.

See Also