Archive

Understanding async and await in .NET

4 min read
  • .NET
  • C#
  • Async

async and await are two of the most useful features in modern .NET because they let an application keep doing work while it waits for slow operations to finish. That matters any time code talks to a database, reads a file, calls an API, sends a message, or waits on another service.

The important idea is simple: asynchronous code is not about making every operation faster. It is about not blocking a thread while the operation is waiting.

The problem async solves

A web request often spends most of its time waiting on I/O. For example, an ASP.NET Core endpoint might call another API and then query a database. If that code blocks a thread while waiting, the server has fewer threads available for other requests.

With await, the method can pause until the operation completes and let the runtime use the thread for other work in the meantime.

app.MapGet("/orders/{id:int}", async (int id, IOrderService orders) =>
{
    Order? order = await orders.GetOrderAsync(id);

    return order is null ? Results.NotFound() : Results.Ok(order);
});

The endpoint still reads top to bottom, but it does not tie up a request thread while GetOrderAsync is waiting.

What the compiler does

When a method is marked async, the C# compiler rewrites it into a state machine. Every await becomes a possible pause point. If the awaited operation has already completed, execution continues immediately. If it has not completed, the method returns control to the caller and resumes later when the operation finishes.

That is why an async method usually returns Task, Task<T>, or ValueTask<T>:

public async Task<CustomerSummary> GetCustomerSummaryAsync(int customerId)
{
    Customer customer = await customers.GetByIdAsync(customerId);
    IReadOnlyList<Order> orders = await orderHistory.GetRecentOrdersAsync(customerId);

    return new CustomerSummary(customer.Name, orders.Count);
}

The returned Task<CustomerSummary> represents work that will produce a CustomerSummary later.

Async is not the same as parallel

await does not automatically start work on another thread. It waits asynchronously for work that is already in progress. For I/O operations, that is exactly what you want.

If two independent operations can run at the same time, start both first and then await them together:

Task<Customer> customerTask = customers.GetByIdAsync(customerId);
Task<IReadOnlyList<Order>> ordersTask = orderHistory.GetRecentOrdersAsync(customerId);

await Task.WhenAll(customerTask, ordersTask);

Customer customer = await customerTask;
IReadOnlyList<Order> orders = await ordersTask;

return new CustomerSummary(customer.Name, orders.Count);

This pattern is useful when the operations do not depend on each other. If the second operation needs data from the first, keep the awaits sequential.

Avoid blocking on async work

The most common async bug is mixing asynchronous code with blocking calls:

// Avoid this.
Customer customer = customers.GetByIdAsync(customerId).Result;

Use await all the way through the call chain instead:

Customer customer = await customers.GetByIdAsync(customerId);

Blocking with .Result, .Wait(), or GetAwaiter().GetResult() can waste threads and may cause deadlocks in some application models. In ASP.NET Core, it also reduces scalability under load.

Pass cancellation tokens

Async code often represents work that a caller may no longer need. In web apps, the client can disconnect. In background services, the host can shut down. Passing a CancellationToken lets your code stop waiting and clean up quickly.

app.MapGet("/customers/{id:int}", async (
    int id,
    CustomerRepository repository,
    CancellationToken cancellationToken) =>
{
    Customer? customer = await repository.GetByIdAsync(id, cancellationToken);

    return customer is null ? Results.NotFound() : Results.Ok(customer);
});

Make cancellation part of the method signature for database calls, HTTP calls, queue operations, and longer-running workflows.

Use ConfigureAwait where it fits

In ASP.NET Core, there is no classic ASP.NET synchronization context, so application code usually does not need ConfigureAwait(false). In reusable libraries, it can still be a good habit because the library should not assume it needs to resume on the caller’s context.

public async Task<string> ReadPayloadAsync(Stream stream, CancellationToken cancellationToken)
{
    using var reader = new StreamReader(stream);
    return await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
}

The rule of thumb is straightforward: application code can stay clean, library code can be more explicit.

A practical checklist

  • Use async for I/O-bound work such as HTTP, database, storage, and messaging calls.
  • Return Task or Task<T> from async methods.
  • Await async calls instead of blocking on them.
  • Use Task.WhenAll for independent operations that can run concurrently.
  • Pass CancellationToken through async APIs.
  • Keep CPU-heavy work separate from I/O-bound async code.

Good async code looks almost ordinary. That is the point. It keeps the readable shape of synchronous C# while giving .NET room to handle more work with fewer blocked threads.