Mistakes and best practices with async & await patterns in C#

Patrick KearnsPatrick Kearns
3 min read

Asynchronous programming is an essential component of modern software development, particularly in C# and the .NET framework. Its ability to greatly enhance application responsiveness, scalability, and resource efficiency has made it indispensable in today's increasingly parallel and network-bound application environments. For instance, web APIs benefit significantly from asynchronous calls by efficiently managing multiple simultaneous requests, thus improving overall throughput and response times. One of the most frequent and critical errors in asynchronous programming is using async void methods. Although convenient, especially in event handlers such as button clicks (async void Button_Click), this pattern lacks proper exception handling capabilities. If an exception occurs within an async void method, it often leads to unhandled exceptions, potentially causing the application to terminate unexpectedly or behave unpredictably. For example, consider this problematic snippet:

async void ProcessData()
{
    await Task.Delay(1000);
    throw new Exception("Error in async void method");
}

To handle exceptions correctly and ensure predictable behavior, developers should consistently use async Task or async Task<T> instead:

async Task ProcessDataAsync()
{
    await Task.Delay(1000);
    throw new Exception("Error in async Task method");
}

// Proper exception handling
try
{
    await ProcessDataAsync();
}
catch (Exception ex)
{
    Console.WriteLine(ex.Message);
}

Another prevalent mistake is forgetting to correctly await asynchronous methods. Omitting the await keyword can inadvertently cause asynchronous operations to run concurrently, resulting in race conditions or unpredictable execution order. For example:

// Incorrect approach
var task1 = Task.Delay(5000);
var task2 = Task.Delay(3000);
// Tasks run concurrently unintentionally

// Correct approach
await Task.Delay(5000);
await Task.Delay(3000); // Tasks run sequentially as intended

Properly awaiting tasks ensures operations complete in the intended sequence, preserving logical flow and predictability of your code.

Mixing synchronous and asynchronous code incorrectly is another critical issue, particularly when using .Result or .Wait() in asynchronous contexts. Such synchronous calls within asynchronous methods can lead to thread-pool starvation or deadlocks, especially in UI or web service environments. For example:

// Problematic code causing potential deadlocks
public async Task FetchDataAsync()
{
    var result = GetDataAsync().Result; // Can cause deadlock
}

// Recommended approach
public async Task FetchDataAsync()
{
    var result = await GetDataAsync(); // Avoids deadlock
}

Maintaining asynchronous methods end to end throughout your application's stack by consistently using await helps avoid such blocking issues entirely.

Implementing cancellation tokens effectively is another essential practice in asynchronous programming. Cancellation tokens enable developers to gracefully handle task cancellations, improving responsiveness and resource management. Properly implemented, cancellation tokens help avoid resource leaks and ensure consistent application state handling, especially in operations that could run indefinitely. Here's a simple example demonstrating cancellation:

public async Task LongRunningOperationAsync(CancellationToken cancellationToken)
{
    for (int i = 0; i < 100; i++)
    {
        cancellationToken.ThrowIfCancellationRequested();
        await Task.Delay(1000, cancellationToken);
    }
}

// Usage
var cts = new CancellationTokenSource();
await LongRunningOperationAsync(cts.Token);
// cts.Cancel(); // Can be called to cancel operation

Finally, careful exception handling within asynchronous methods is crucial. Exceptions should be caught within tasks and appropriately propagated to calling methods, allowing applications to recover or provide meaningful user feedback. Properly structured Try/Catch blocks within asynchronous code ensure that errors are managed predictably:

public async Task DoWorkAsync()
{
    try
    {
        await Task.Run(() => throw new Exception("Task failure"));
    }
    catch (Exception ex)
    {
        Console.WriteLine($"Caught exception: {ex.Message}");
        throw; // Re-throw if further handling is required upstream
    }
}

Understanding these common mistakes and sticking to recommended best practices in asynchronous programming makes us write clearer, safer, and more maintainable code. Consequently, adopting these practices significantly enhances application stability, responsiveness, and user experience, ensuring modern applications efficiently meet user expectations and business requirements.

0
Subscribe to my newsletter

Read articles from Patrick Kearns directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Patrick Kearns
Patrick Kearns