Async and Await in .NET: Practical C# Guide
Async and Await in .NET: Practical C# Guide
Async and await in .NET help developers build applications that remain responsive while waiting for work such as database calls, HTTP requests, file operations, or other I/O tasks. Instead of blocking a thread until an operation finishes, asynchronous code lets the method pause at an await point and return control to its caller. When the operation completes, execution continues from that point. This model makes complex asynchronous workflows easier to read than older callback-based approaches. For .NET developers, understanding Task, async, await, cancellation, exception handling, and concurrency is important because modern APIs frequently expose asynchronous methods. This guide explains how the model works, when to use it, how to compose multiple tasks, and which common mistakes can reduce the benefits of asynchronous programming.
What Are Async and Await in .NET?
The async keyword marks a method as asynchronous and allows that method to use await. The await keyword waits asynchronously for an awaitable operation to finish. In most application code, that operation appears as a Task or Task<T>. A Task represents work that may still be running, while Task<T> also carries a result.
An important detail often causes confusion: async does not automatically move a method to a background thread. An async method normally starts running synchronously and continues until it reaches an await that needs to wait for an incomplete operation. At that point, the method yields control and later resumes when the awaited operation completes. This distinction matters when you analyze performance, thread usage, and application behavior.
Why Asynchronous Programming Matters
Synchronous code can hold a thread while it waits for a database, network service, or file system operation. In a web application, many simultaneous requests can create a large number of waiting threads. Asynchronous I/O lets the application release the current thread while the external operation progresses, so the runtime can use available resources for other work.
The benefit is especially clear in ASP.NET Core APIs. Suppose an endpoint calls a database and then an external payment service. Neither operation needs the CPU continuously. Waiting synchronously adds unnecessary thread blocking. With async and await, the request can wait without tying up a worker thread for the entire I/O period.
Asynchronous programming also improves user experience in desktop applications. A UI thread should not freeze while an application downloads data or reads a large file. The same principle applies to server applications: avoid blocking threads when an asynchronous API already exists.
How async, await, and Task Work Together
A typical asynchronous method returns Task when it has no result and Task<T> when it produces a result. The caller can await that task and receive the result without blocking the thread while the operation waits.
public async Task<string> GetCustomerNameAsync(int customerId)
{
var customer = await customerRepository.GetByIdAsync(customerId);
return customer.Name;
}
The method above has three important parts. First, async allows the method to use await. Second, the repository returns a Task representing the database operation. Third, await resumes the method when the result becomes available. The caller receives a string after the asynchronous operation completes.
For methods that return no value, use Task rather than async void in normal application code. Task gives callers something they can await and allows exceptions to flow through the task. Reserve async void mainly for event handlers that require that signature.
Async and Await in .NET for I/O-Bound Work
I/O-bound work spends much of its time waiting for an external resource. Common examples include SQL queries, HTTP requests, file access, cloud storage, and message brokers. When an API exposes an asynchronous operation, await that operation instead of wrapping the call in Task.Run just to make it look asynchronous.
Calling an HTTP API
HttpClient provides asynchronous methods for network calls. The following service calls an API and returns the response content.
public async Task<string> GetProductDataAsync(
HttpClient httpClient,
CancellationToken cancellationToken)
{
using var response = await httpClient.GetAsync(
"https://api.example.com/products",
cancellationToken);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync(cancellationToken);
}
The CancellationToken allows the caller to stop the operation when it no longer needs the result. In ASP.NET Core, request cancellation can flow from the request pipeline into downstream operations. That behavior helps avoid unnecessary work when a client disconnects or a request times out.
Sequential vs Concurrent Asynchronous Operations
Using await does not automatically make independent operations run concurrently. Consider an API that needs customer data and product recommendations from two independent services. If you await the first request before starting the second, the calls run one after another.
var customer = await customerService.GetAsync(customerId);
var recommendations = await recommendationService.GetAsync(customerId);
When the operations do not depend on each other, start both tasks first and await them together.
Task<Customer> customerTask = customerService.GetAsync(customerId);
Task<IReadOnlyList<Product>> recommendationTask =
recommendationService.GetAsync(customerId);
await Task.WhenAll(customerTask, recommendationTask);
Customer customer = await customerTask;
IReadOnlyList<Product> recommendations = await recommendationTask;
Task.WhenAll completes when all supplied tasks finish. This pattern can reduce total waiting time when independent I/O operations overlap. Do not use concurrency blindly, though. Starting hundreds of database or HTTP operations at once can overload the downstream system. Match concurrency to the capacity and limits of the resources you call.
Exception Handling with Async Code
Asynchronous methods still use normal C# exception handling. Place await inside a try block when the caller needs to handle an exception from that operation.
try
{
var order = await orderService.CreateAsync(request);
return Results.Ok(order);
}
catch (PaymentException ex)
{
logger.LogWarning(ex, "Payment failed for the order.");
return Results.BadRequest("Payment could not be completed.");
}
Await observes the task and propagates an exception to the awaiting code. This approach is easier to reason about than manually inspecting task status. In larger ASP.NET Core applications, centralized exception handling can keep individual endpoints focused on normal application behavior. Our guide to Global Exception Handling in .NET explains a centralized approach for APIs.
Cancellation and Timeouts
Long-running asynchronous work should often support cancellation. A CancellationToken gives the caller a way to signal that the operation should stop. Supporting cancellation matters for HTTP requests, background processing, file transfers, and database operations.
public async Task<Order?> GetOrderAsync(
int orderId,
CancellationToken cancellationToken)
{
return await dbContext.Orders
.FirstOrDefaultAsync(
order => order.Id == orderId,
cancellationToken);
}
Cancellation is cooperative. Passing a token does not forcibly terminate arbitrary code. The called API must observe the token and respond appropriately. When you create your own asynchronous operation, pass the token to downstream APIs and check it during long-running loops where appropriate.
Async and Await in .NET: Common Mistakes
Blocking with Result or Wait
One of the most common mistakes is starting asynchronous work and then blocking synchronously on it.
var result = GetDataAsync().Result;
This approach defeats the purpose of asynchronous code and can cause thread starvation. Some environments can also encounter deadlock problems when synchronous blocking interacts with a captured synchronization context. Prefer await throughout the call chain whenever the API design allows it.
Using Task.Run for I/O Operations
Task.Run is useful when you need to move CPU-intensive work to a thread-pool thread. It does not make an inherently synchronous I/O API truly asynchronous. Wrapping database or HTTP calls in Task.Run can add thread-pool overhead without solving the underlying problem.
Forgetting to Await a Task
Calling an asynchronous method without awaiting its task can create timing, exception, and data consistency problems.
SaveOrderAsync(order);
SendConfirmationAsync(order);
If the caller needs both operations to complete, await them. If the application intentionally starts independent work without waiting, it needs a deliberate background-work design with proper lifetime, logging, cancellation, and exception handling.
Using async void Unnecessarily
Async void removes the Task that callers normally use to observe completion and exceptions. In application services, prefer Task or Task<T>. Event handlers remain a common exception because their framework-defined signature may require void.
Async Code in ASP.NET Core Applications
ASP.NET Core applications benefit from async database calls, HTTP calls, file operations, and other I/O work. Keep the asynchronous flow consistent from the controller or endpoint through the service and repository layers. If a repository exposes GetByIdAsync, the service can await it directly rather than blocking for the result.
Dependency Injection also fits naturally with asynchronous services because dependencies can expose asynchronous contracts without forcing the caller to create concrete implementations. If you want to review service registration and constructor injection, see our Dependency Injection in ASP.NET Core guide.
For database access, choose APIs that support asynchronous execution, such as Entity Framework Core methods ending in Async. Keep in mind that async improves resource usage during I/O waits; it does not automatically make a slow SQL query faster. Query design, indexes, network latency, and database capacity still affect total execution time.
Async Programming and Background Services
Background workers often depend heavily on asynchronous operations. A worker might read messages, call an external API, save results, and then wait for more work. Async methods let the worker wait without unnecessarily blocking a thread.
Cancellation matters even more in long-running services. When the host shuts down, the worker should observe the supplied cancellation token and stop cleanly. Our Background Services in .NET guide covers hosted services, cancellation, scoped dependencies, scheduling, and production considerations.
Best Practices for Async and Await
- Use asynchronous APIs for I/O-bound work when the framework or library provides them.
- Prefer Task and Task<T> return types for application methods.
- Use the Async suffix for asynchronous method names when the method is not a framework-defined callback.
- Await tasks instead of using Result, Wait, or WaitAll.
- Pass CancellationToken through layers when cancellation has meaning.
- Start independent operations before awaiting them when concurrency makes sense.
- Control concurrency when calling databases, APIs, queues, or other limited resources.
- Use Task.Run mainly for suitable CPU-bound work rather than as a wrapper around I/O.
- Handle exceptions at a layer that can make a meaningful recovery or reporting decision.
- Keep asynchronous methods focused so callers can understand what each task represents.
Async and Await in .NET: Practical Design Example
Consider an order API that validates an order, loads customer information, checks inventory, and sends a confirmation message. Validation may execute synchronously if it performs only in-memory checks. Customer and inventory lookups may run asynchronously because they access external resources. After both independent lookups start, the service can await them together.
public async Task<OrderResult> ProcessOrderAsync(
OrderRequest request,
CancellationToken cancellationToken)
{
ValidateRequest(request);
Task<Customer> customerTask =
customerService.GetAsync(request.CustomerId, cancellationToken);
Task<Inventory> inventoryTask =
inventoryService.CheckAsync(request.ProductId, cancellationToken);
await Task.WhenAll(customerTask, inventoryTask);
var customer = await customerTask;
var inventory = await inventoryTask;
if (!inventory.IsAvailable)
return OrderResult.Failed("Product is unavailable.");
var order = await orderService.CreateAsync(
request, customer, cancellationToken);
await notificationService.SendConfirmationAsync(
order, cancellationToken);
return OrderResult.Success(order);
}
This example shows the main design goal of asynchronous code: make waiting explicit without turning the application into a collection of blocked threads. Each service exposes a clear asynchronous contract, independent I/O operations overlap, and cancellation flows through the operation. For a broader architectural view of these patterns, see our Clean Architecture in .NET guide.
When Async Does Not Improve Performance
Async does not make every operation faster. A CPU-intensive calculation still consumes CPU time whether the surrounding method uses async or not. If an operation performs heavy computation, measure it before deciding how to execute it. For suitable CPU-bound workloads, Task.Run can move work to a thread-pool thread, but that choice should reflect the application’s architecture and workload.
Likewise, async cannot fix a poorly optimized database query. If a query takes several seconds because it scans millions of rows, changing await syntax will not reduce the database execution time. Async mainly helps the application use waiting time more efficiently.
Further Learning
For the official language and framework guidance, see Microsoft Learn: Asynchronous programming with async and await. The documentation explains the Task-based asynchronous pattern, concurrent task composition, exception handling, and efficient use of await.
Conclusion
Async and await in .NET provide a readable way to build responsive and scalable applications around asynchronous operations. The key is to understand what async actually does: it enables await and allows a method to suspend without blocking the current thread while an incomplete asynchronous operation runs. Task represents ongoing work, Task<T> represents work with a result, and Task.WhenAll helps coordinate independent operations. Good async code also handles exceptions, supports cancellation, avoids synchronous blocking, and uses concurrency carefully. When developers apply these practices consistently across APIs, services, repositories, and background workers, async and await in .NET become practical tools for building reliable applications rather than keywords added only for performance claims.