Mastering Async and Await in C#: Best Practices

The async/await pattern in C# simplifies asynchronous programming, making code easier to read and maintain. However, improper use can lead to bugs, performance issues, and deadlocks. Here are some best practices to ensure you’re using async/await effectively:

Use async All the Way

Always propagate async methods throughout your call stack. Avoid blocking calls like .Result or .Wait() on asynchronous operations, which can cause deadlocks.

Bad example:

var result = GetDataAsync().Result;


Good example:

var result = await GetDataAsync();

Return Task, Not void

Asynchronous methods should return Task or Task<T> except for event handlers. Using void makes error handling difficult because it doesn’t return a task for the caller to observe.

Bad example:

async void ProcessDataAsync() {}

Good example:

async Task ProcessDataAsync() {}

Avoid Fire-and-Forget

Unobserved exceptions in fire-and-forget tasks can lead to silent failures. If you need fire-and-forget behavior, log errors or handle them explicitly.

Bad example:

ProcessDataAsync(); // Task not awaited, potential issues ignored

Good example:

ProcessDataAsync()
    .ContinueWith(t => LogError(t.Exception), TaskContinuationOptions.OnlyOnFaulted); 

Use ConfigureAwait(false) Where Appropriate

By default, await captures the current synchronization context, which is unnecessary in libraries or non-UI code. Use ConfigureAwait(false) to improve performance and prevent deadlocks.

Example:

await ProcessDataAsync().ConfigureAwait(false);

Modern ASP.NET Core applications do not have a synchronization context, as the framework is designed to optimize for asynchronous operations. Because of this, using ConfigureAwait(false) is generally not required in ASP.NET Core apps.

If you’re writing reusable libraries (mainly ones used outside ASP.NET Core, like in desktop apps or legacy frameworks), using ConfigureAwait(false) is still a good practice to avoid inadvertently tying your code to a synchronization context.

Avoid Async Void Lambdas

Use async lambdas that return Task instead of void for better exception handling.

Bad example:

var _ = async () => { await ProcessDataAsync(); };

Good example:

var _ = async () => await ProcessDataAsync();

Respect Cancellation Token

When working with asynchronous operations,  respect CancellationToken to allow graceful task termination.

async Task GetDataAsync(CancellationToken token)
{
    await Task.Delay(1000, token);
}

Use ValueTask Sparingly

ValueTask can optimize frequent synchronous completions but introduces complexity. Stick to Task unless performance profiling justifies ValueTask.

Understand better ValueTask in my previous post

Use WhenAll for Concurrent Tasks

Use Task.WhenAll for running many asynchronous tasks in parallel is often the better choice when you want to wait for multiple tasks to complete efficiently.

Example:

var result1 = await DoWorkAsync(1);
var result2 = await DoWorkAsync(2);
var result3 = await DoWorkAsync(3);

Better example:

List<Task<int>> tasks = [ 
        DoWorkAsync(1), 
        DoWorkAsync(2), 
        DoWorkAsync(3) 
    ];

var results = await Task.WhenAll(tasks);

Use IAsyncEnumerable for Asynchronous Streams 

Using IAsyncEnumerable<T> is often the preferred approach for handling asynchronous streams in C#, especially when dealing with large datasets or streams of data that arrive over time. It allows you to process data lazily and asynchronously, making your code more efficient and memory-friendly compared to alternatives like returning a List<T> or Task<IEnumerable<T>>.

Example:

await foreach (var item in GetItemsAsync())
{
    Console.WriteLine(item);
}

async IAsyncEnumerable<int> GetItemsAsync()
{
    for (int i = 0; i < 10; i++)
    {
        await Task.Delay(100); // Simulate async work
        yield return i;
    }
}

Async in Sync Context

Calling an async method from a synchronous method is tricky and should generally be avoided because it can lead to deadlocks, unexpected behavior, or degraded performance. However, there are scenarios where it might be necessary.

Use .GetAwaiter().GetResult()

This is the safest option to call an async method synchronously without risking deadlocks, as it bypasses the SynchronizationContext.

Example:

void SyncMethod()
{
    var result = ProcessDataAsync().GetAwaiter().GetResult();
}

Summary

By following these best practices, you can write cleaner, more reliable, and performant asynchronous code in C#. Always strive for clarity and handle edge cases to ensure your code behaves as expected.

Scroll to Top