A Guide to C# async/await Best Practices

A summary of the most important best practices for writing asynchronous code in C# with async and await. Learn how to avoid common pitfalls like deadlocks and how to write clean, efficient async code.

The async and await keywords in C# have made asynchronous programming dramatically simpler. However, writing correct and efficient async code requires understanding a few key best practices. Following these guidelines will help you avoid common pitfalls and get the most out of the async/await pattern.

1. Async All the Way

This is the most important rule. Once you start writing async code, you should prefer to let it flow through your entire call stack. If you call an async method, you should await it, which means your calling method must also be async.

Don't block on async code. Avoid using methods like Task.Wait() or Task.Result. This is known as "sync over async" and it can lead to serious problems, most notably deadlocks, especially in UI and ASP.NET Classic applications.

Bad Practice:

public void MyMethod()
{
    // This can cause deadlocks!
    var result = MyAsyncMethod().Result;
}

Good Practice:

public async Task MyMethodAsync()
{
    var result = await MyAsyncMethod();
}

2. Use ConfigureAwait(false) in Libraries

When you await a task, the default behavior is to capture the current synchronization context and resume the execution on that same context after the await completes. In a UI application, this is the UI context. In an ASP.NET (pre-Core) application, this is the request context.

This is useful for application-level code, but it's unnecessary and potentially harmful for general-purpose library code. In a library, you should use ConfigureAwait(false) to tell the runtime that it can resume the execution on any available thread pool thread.

public async Task<string> GetDataAsync()
{
    var client = new HttpClient();
    // Don't need to resume on the original context
    var content = await client.GetStringAsync("http://example.com").ConfigureAwait(false);
    return content;
}

Using ConfigureAwait(false) improves performance and helps prevent deadlocks. This is a best practice for any non-application-level code.

3. Return Task or Task<T>, Not void

An async method can have three possible return types:

  • Task: For an async method that does not return a value.
  • Task<T>: For an async method that returns a value of type T.
  • void: Avoid this!

An async void method is a "fire and forget" method. The caller has no way to await it, and more importantly, any exceptions thrown from an async void method cannot be caught in a standard try-catch block and will typically crash the process.

The only valid use case for async void is for event handlers (e.g., a button click event in a UI application), which are required by the event system to have a void return type.

4. Prefer ValueTask<T> for Performance-Critical, Synchronous Scenarios

For most application code, Task<T> is the correct choice. However, if you are writing a high-performance library where an async method is expected to complete synchronously very often (e.g., by returning a value from a cache), you can use ValueTask<T> to avoid allocating a Task object on the heap. This is an advanced optimization and should only be used when you've identified a performance bottleneck.

5. Use CancellationToken for Cancellable Operations

Many asynchronous operations can take a long time. It's good practice to make them cancellable. You can do this by accepting a CancellationToken as a parameter to your async method and passing it down to the other async methods you call.

public async Task DoWorkAsync(CancellationToken cancellationToken)
{
    // Pass the token to other async calls
    await Task.Delay(1000, cancellationToken);

    // Periodically check if cancellation has been requested
    cancellationToken.ThrowIfCancellationRequested();
}

This allows the caller to cancel the operation if it's no longer needed.

Conclusion

async and await are incredibly powerful features for writing responsive and scalable .NET applications. By following these best practices—especially "async all the way" and using ConfigureAwait(false) in libraries—you can avoid common pitfalls and write asynchronous code that is clean, correct, and efficient.