Skip to content

Async programming

How async/await works in C# and how it has evolved across versions.

Asynchronous programming lets your code start a long-running operation (a network request, a file read, a database query) and continue doing other work while it completes, rather than blocking the current thread. C# has built-in language support for this through the async and await keywords, making asynchronous code almost as easy to read as synchronous code.

The problem async solves

Before async/await, long-running I/O had two options:

  1. Block the thread. Simple to write but wastes resources. A blocked thread cannot serve other requests, which kills scalability in servers and freezes the UI in desktop apps.
  2. Use callbacks. Scalable but the code becomes deeply nested, hard to read, and difficult to debug. Error handling is especially painful.
C#
// Blocking: simple but wasteful
string html = httpClient.GetStringAsync(url).Result; // thread sits idle

// Callbacks: scalable but messy
httpClient.GetStringAsync(url).ContinueWith(task =>
{
    if (task.IsFaulted) HandleError(task.Exception);
    else Process(task.Result);
});

async and await

Async/await C# 5.0 transforms the callback approach into straight-line code. The compiler rewrites an async method into a state machine that suspends at each await and resumes when the awaited operation completes, without blocking a thread.

C#
async Task<string> FetchPageAsync(string url)
{
    string html = await httpClient.GetStringAsync(url);
    return html; // execution resumes here when the download finishes
}
  • async marks a method as asynchronous. It enables the await keyword inside the method body and changes the return type to Task, Task<T>, or ValueTask<T>.
  • await suspends the method until the awaited task completes, then unwraps the result. Control returns to the caller while waiting.

The method looks synchronous but behaves asynchronously. Exceptions propagate naturally, using and try/catch/finally work as expected, and the code reads top to bottom.

Task and ValueTask

The return type of an async method tells the caller how to observe the result:

Return typeUse when
TaskThe method has no return value (like void but awaitable)
Task<T>The method returns a value of type T
ValueTask<T>The method often completes synchronously (avoids a Task allocation)
voidFire-and-forget event handlers only; exceptions cannot be observed

ValueTask<T> was introduced in .NET Core 2.1 for hot paths where the result is frequently available immediately (e.g. reading from a buffered stream). Unlike Task<T>, a ValueTask<T> should only be awaited once and should not be stored for later use.

How it works under the hood

The compiler transforms an async method into a state machine struct. Each await becomes a state transition:

  1. If the awaited task is already complete, execution continues immediately with no allocation and no thread switch.
  2. If the task is incomplete, the state machine records where to resume, registers a continuation, and returns to the caller.
  3. When the task completes, the continuation runs (typically on a thread pool thread, or back on the original context if there is one).

This means async/await has zero overhead when the operation completes synchronously, and minimal overhead otherwise (just the state machine and a continuation registration).

ConfigureAwait

By default, await captures the current synchronization context and resumes on it. In a UI app, this means the code after await runs on the UI thread. In ASP.NET Core (which has no sync context), this is a no-op.

ConfigureAwait(false) tells the runtime not to capture the context and to resume on whatever thread is available. This avoids unnecessary thread switches and prevents deadlocks in library code.

C#
// Library code: always use ConfigureAwait(false)
var data = await httpClient.GetStringAsync(url).ConfigureAwait(false);

// UI code: do NOT use ConfigureAwait(false) if you need to update the UI after
var data = await httpClient.GetStringAsync(url);
lblStatus.Text = data; // must run on UI thread

Rule of thumb: use ConfigureAwait(false) in library code and shared infrastructure. Omit it in application-level UI code where you need the context.

Cancellation

Async operations should support cancellation via CancellationToken. This lets callers abort work they no longer need, which is important for responsiveness and resource management.

C#
async Task<string> FetchAsync(string url, CancellationToken ct = default)
{
    var response = await httpClient.GetAsync(url, ct);
    response.EnsureSuccessStatusCode();
    return await response.Content.ReadAsStringAsync(ct);
}

// Caller
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
try
{
    var html = await FetchAsync(url, cts.Token);
}
catch (OperationCanceledException)
{
    Console.WriteLine("Request timed out");
}

Pass the token through to every async API that accepts one. If your method does CPU-bound work in a loop, check ct.ThrowIfCancellationRequested() periodically.

Async streams

Async streams C# 8.0 combine async/await with yield return to produce sequences of values asynchronously. The consumer processes each item as it arrives rather than waiting for the entire collection.

C#
async IAsyncEnumerable<string> ReadLinesAsync(string path)
{
    using var reader = new StreamReader(path);
    while (await reader.ReadLineAsync() is { } line)
        yield return line;
}

await foreach (var line in ReadLinesAsync("data.txt"))
    Console.WriteLine(line);

IAsyncEnumerable<T> is the async counterpart of IEnumerable<T>. It supports cancellation via WithCancellation() and is used extensively in ASP.NET Core, Entity Framework Core, and gRPC for streaming results.

Async disposal

IAsyncDisposable (introduced in .NET Core 3.0) allows cleanup of resources that require asynchronous work such as flushing buffered writes or closing network connections.

C#
await using var connection = new SqlConnection(connectionString);
await connection.OpenAsync();
// connection.DisposeAsync() called automatically at end of scope

The await using declaration works just like using but calls DisposeAsync() instead of Dispose().

Common mistakes

Async void

async void methods cannot be awaited, so exceptions crash the process instead of propagating to the caller. Only use async void for event handlers where the delegate signature requires void.

C#
// Bad: exception is unobservable
async void LoadData() { ... }

// Good: caller can await and catch exceptions
async Task LoadDataAsync() { ... }

Blocking on async code

Calling .Result or .Wait() on a task from synchronous code can deadlock if there is a synchronization context (WPF, WinForms, old ASP.NET). The await tries to resume on the captured context, but that thread is blocked waiting for the result.

C#
// Deadlock risk on UI thread or legacy ASP.NET
var result = GetDataAsync().Result; // blocks the context thread

// Safe alternatives
var result = await GetDataAsync();                          // best: go async all the way
var result = GetDataAsync().ConfigureAwait(false).Result;   // workaround if you truly can't use await

The best solution is "async all the way". If you call an async method, your method should also be async.

Creating unnecessary tasks

Task.Run is for offloading CPU-bound work to the thread pool. Do not wrap an already-async I/O call in Task.Run as it wastes a thread pool thread just to call await.

C#
// Wasteful: Task.Run adds nothing here
var data = await Task.Run(() => httpClient.GetStringAsync(url));

// Correct: already async, no wrapping needed
var data = await httpClient.GetStringAsync(url);

Evolution timeline

VersionFeatureLink
C# 5.0async and await keywordsAsynchronous members
C# 7.1async MainAsync main
C# 8.0await foreach and IAsyncEnumerable<T>Async streams
C# 8.0await using and IAsyncDisposableUsing declarations
C# 13.0ref locals and unsafe in async methodsref/unsafe in async

Tips

  • Go async all the way. Mixing sync and async leads to deadlocks and thread pool starvation. If your entry point is async, every layer below it should be too.
  • Prefer await over ContinueWith. await handles exceptions, cancellation, and context flow correctly by default; ContinueWith requires you to handle all of that manually.
  • Return the task directly when your method simply delegates to another async method and has no finally/using cleanup. This avoids an unnecessary state machine.
  • Name async methods with an Async suffix. ReadAsync, SaveAsync, etc. This is a strong .NET convention that makes call sites immediately recognizable.
  • Do not use async on methods that simply return an existing task. The async keyword adds a state machine wrapper that is unnecessary if you have nothing to await.
C#
// Unnecessary state machine, just return the task
async Task<string> GetAsync(string url)
    => await httpClient.GetStringAsync(url); // adds overhead for no reason

// Better: returns the task directly
Task<string> GetAsync(string url)
    => httpClient.GetStringAsync(url);

// BUT keep async if you have using/try-finally, you need the state machine for cleanup
async Task<string> GetAsync(string url)
{
    using var response = await httpClient.GetAsync(url);
    return await response.Content.ReadAsStringAsync();
}