Skip to main content

C# Async Best Practices

Asynchronous programming in C# can significantly improve your application's responsiveness and efficiency, but it also introduces complexity that can lead to bugs and performance issues if not handled properly. This guide will walk you through essential best practices to help you write clean, efficient, and reliable asynchronous code.

Introduction

Asynchronous programming allows operations to run independently of your application's main execution thread. When implemented correctly, it enables applications to:

  • Remain responsive during long-running operations
  • Process multiple operations concurrently
  • Efficiently utilize system resources
  • Improve overall application performance

However, without proper implementation, async code can introduce subtle bugs, memory leaks, or even degrade performance. Let's explore the most important best practices to follow when working with async code in C#.

Essential Async Best Practices

1. Always Use ConfigureAwait(false) in Library Code

When you create libraries that will be consumed by other applications, use ConfigureAwait(false) to prevent potential deadlocks:

csharp
// Good practice in library code
public async Task<string> GetDataFromApiAsync()
{
HttpClient client = new HttpClient();
string response = await client.GetStringAsync("https://api.example.com/data")
.ConfigureAwait(false);
return response;
}

Why it matters: When you use await without ConfigureAwait(false), the awaited task captures the current synchronization context and returns execution to it when complete. In UI applications, this ensures UI updates happen on the UI thread. However, in libraries, this behavior can cause deadlocks if the library is called from a UI thread that's waiting synchronously for the task to complete.

Note: For application code (especially UI applications), it's often better to omit ConfigureAwait(false) so that execution continues on the appropriate thread.

2. Avoid Async Void

Always return Task or Task<T> from async methods instead of void, except for event handlers:

csharp
// Bad practice
public async void ProcessDataAsync() // Avoid this!
{
await Task.Delay(1000);
// Process data
}

// Good practice
public async Task ProcessDataAsync()
{
await Task.Delay(1000);
// Process data
}

Why it matters: Async void methods:

  • Cannot be awaited
  • Don't provide a way to notify the caller when they complete
  • Make error handling much more difficult
  • Can cause unhandled exceptions to crash your application

Exception: Event handlers where a return type can't be changed:

csharp
// Acceptable use of async void
private async void Button_Click(object sender, EventArgs e)
{
await ProcessDataAsync();
ResultLabel.Text = "Processing complete!";
}

3. Always Use Async/Await Together

Don't mix async and synchronous code styles:

csharp
// Bad practice
public Task<int> CalculateAsync()
{
// Starting a task but not awaiting it
return Task.Run(() =>
{
// Calculation
return 42;
});
}

// Good practice
public async Task<int> CalculateAsync()
{
await Task.Delay(100); // Simulating work
return 42;
}

Why it matters: The async/await pattern makes asynchronous code more readable and maintainable by allowing you to write it in a sequential style. Mixing styles leads to harder-to-understand code and potential bugs.

4. Use Task.WhenAll for Parallel Operations

When you need to run multiple independent async operations, use Task.WhenAll to run them concurrently:

csharp
// Sequential execution (slower)
public async Task<int> GetTotalSequentialAsync()
{
int result1 = await GetValueAsync(1);
int result2 = await GetValueAsync(2);
int result3 = await GetValueAsync(3);

return result1 + result2 + result3;
}

// Parallel execution (faster)
public async Task<int> GetTotalParallelAsync()
{
Task<int> task1 = GetValueAsync(1);
Task<int> task2 = GetValueAsync(2);
Task<int> task3 = GetValueAsync(3);

int[] results = await Task.WhenAll(task1, task2, task3);

return results.Sum();
}

Why it matters: Running independent operations in parallel can significantly improve performance by reducing the total wait time.

5. Use Cancellation Tokens

Always support cancellation in asynchronous methods to allow callers to cancel operations:

csharp
public async Task<string> FetchDataAsync(CancellationToken cancellationToken = default)
{
HttpClient client = new HttpClient();

// Pass the token to methods that support it
return await client.GetStringAsync("https://api.example.com/data", cancellationToken);
}

// Usage example:
public async Task CallerMethodAsync()
{
CancellationTokenSource cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));

try
{
string result = await FetchDataAsync(cts.Token);
Console.WriteLine(result);
}
catch (OperationCanceledException)
{
Console.WriteLine("Operation was cancelled after timeout");
}
}

Why it matters: Cancellation tokens allow you to gracefully abort long-running operations, prevent wasted resources, and improve responsiveness when operations are no longer needed.

6. Use Task.FromResult for Immediate Results

When implementing async methods where you sometimes have data immediately available:

csharp
public async Task<int> GetValueAsync(int id)
{
// Check if we have a cached result
if (_cache.TryGetValue(id, out int cachedValue))
{
// Return immediate result without unnecessary async overhead
return await Task.FromResult(cachedValue);
}

// Otherwise do actual async work
int result = await FetchValueFromDatabaseAsync(id);
_cache.Add(id, result);
return result;
}

Why it matters: Task.FromResult creates a completed task without the overhead of unnecessary async state machine creation, improving performance when results are immediately available.

7. Prefer Async/Await Over Lower-Level Alternatives

Use the high-level async/await pattern instead of lower-level Task APIs when possible:

csharp
// Hard to read, error-prone approach
public Task<int> CalculateTotalLowLevelAsync()
{
return Task.Run(() =>
{
int sum = 0;
for (int i = 0; i < 1000; i++)
{
sum += i;
}
return sum;
});
}

// Clearer, safer approach
public async Task<int> CalculateTotalAsync()
{
int sum = 0;
for (int i = 0; i < 1000; i++)
{
sum += i;
}
return sum;
}

Why it matters: The async/await pattern handles the complex details of asynchronous programming for you, making your code easier to write, read, and maintain.

8. Avoid Blocking Async Code

Never block on async code using .Result, .Wait(), or similar methods:

csharp
// Bad practice - can cause deadlocks
public int GetData()
{
// Blocking on async code - DON'T DO THIS!
return GetDataAsync().Result;
}

// Good practice
public async Task<int> GetDataAsync()
{
await Task.Delay(100); // Simulating work
return 42;
}

// Good practice for Main method or other entry points that can't be async
public static void Main()
{
// If you absolutely must block, do it carefully
Task.Run(async () => await RunApplicationAsync())
.GetAwaiter()
.GetResult();
}

Why it matters: Blocking on async code can cause deadlocks, especially in UI applications, and defeats the purpose of using asynchronous programming in the first place.

9. Handle Exceptions Properly

Always handle exceptions in async code properly:

csharp
public async Task ProcessDataAsync()
{
try
{
await FetchDataAsync();
await SaveDataAsync();
}
catch (HttpRequestException ex)
{
// Handle network-related exceptions
Console.WriteLine($"Network error: {ex.Message}");
}
catch (Exception ex)
{
// Handle other exceptions
Console.WriteLine($"Error processing data: {ex.Message}");
}
}

Why it matters: Unhandled exceptions in async methods can be difficult to trace and debug. Always use try-catch blocks to properly handle exceptions, especially in top-level async methods.

10. Name Async Methods Appropriately

Always append "Async" to the names of methods that return Task or Task<T>:

csharp
// Good naming convention
public async Task<User> GetUserByIdAsync(int userId)
{
// Implementation
return await _userRepository.FindByIdAsync(userId);
}

Why it matters: This convention makes it clear to callers that the method is asynchronous and that the return value should be awaited.

Real-World Example: API Data Service

Let's put these best practices together in a real-world example of a service that fetches and processes data from an API:

csharp
public class DataService
{
private readonly HttpClient _httpClient;
private readonly ILogger<DataService> _logger;
private readonly Dictionary<string, string> _cache = new Dictionary<string, string>();

public DataService(HttpClient httpClient, ILogger<DataService> logger)
{
_httpClient = httpClient;
_logger = logger;
}

public async Task<IEnumerable<Product>> GetProductsAsync(CancellationToken cancellationToken = default)
{
try
{
// Check cache first
if (_cache.TryGetValue("products", out string cachedData))
{
_logger.LogInformation("Returning cached products data");
return await Task.FromResult(JsonSerializer.Deserialize<IEnumerable<Product>>(cachedData));
}

// Fetch data from API with cancellation support
string json = await _httpClient.GetStringAsync("api/products", cancellationToken)
.ConfigureAwait(false);

// Store in cache
_cache["products"] = json;

// Process and return results
return JsonSerializer.Deserialize<IEnumerable<Product>>(json);
}
catch (OperationCanceledException)
{
_logger.LogWarning("Product data request was cancelled");
throw; // Rethrow to notify caller
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching product data");
return Enumerable.Empty<Product>(); // Return empty result on error
}
}

public async Task<(Product[] NewProducts, Product[] UpdatedProducts)> SyncProductsAsync(
IEnumerable<Product> localProducts,
CancellationToken cancellationToken = default)
{
// Get all current products from API
IEnumerable<Product> apiProducts = await GetProductsAsync(cancellationToken);

// Process which products are new or updated in parallel
Task<Product[]> newTask = Task.Run(() =>
apiProducts.Where(p => !localProducts.Any(lp => lp.Id == p.Id)).ToArray(),
cancellationToken);

Task<Product[]> updatedTask = Task.Run(() =>
apiProducts.Where(p => localProducts.Any(lp => lp.Id == p.Id && lp.LastUpdated < p.LastUpdated)).ToArray(),
cancellationToken);

// Wait for both operations to complete
await Task.WhenAll(newTask, updatedTask);

// Return results
return (newTask.Result, updatedTask.Result);
}
}

// Usage example:
public class ProductManager
{
private readonly DataService _dataService;

public ProductManager(DataService dataService)
{
_dataService = dataService;
}

public async Task UpdateCatalogAsync()
{
using CancellationTokenSource cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));

try
{
List<Product> localProducts = await LoadLocalProductsAsync();

(Product[] newProducts, Product[] updatedProducts) =
await _dataService.SyncProductsAsync(localProducts, cts.Token);

// Process updates
await SaveNewProductsAsync(newProducts);
await UpdateExistingProductsAsync(updatedProducts);

Console.WriteLine($"Added {newProducts.Length} new products and updated {updatedProducts.Length} existing products");
}
catch (OperationCanceledException)
{
Console.WriteLine("Product sync operation timed out");
}
catch (Exception ex)
{
Console.WriteLine($"Error updating catalog: {ex.Message}");
}
}

// Other implementation methods...
}

This example demonstrates:

  • Using cancellation tokens
  • Appropriate error handling
  • Caching with Task.FromResult for immediate results
  • Using ConfigureAwait(false) in library code
  • Parallel processing with Task.WhenAll
  • Proper naming conventions
  • Avoiding blocking operations

Summary

Following these best practices will help you write robust, efficient, and maintainable asynchronous code in C#:

  1. Use ConfigureAwait(false) in library code
  2. Avoid async void methods
  3. Always use async/await together
  4. Use Task.WhenAll for parallel operations
  5. Support cancellation with CancellationToken
  6. Use Task.FromResult for immediate results
  7. Prefer async/await over lower-level alternatives
  8. Never block on async code
  9. Handle exceptions properly
  10. Name async methods with the "Async" suffix

By applying these practices consistently, you'll avoid common pitfalls and create applications that remain responsive and error-free.

Additional Resources

Exercises

  1. Refactor Challenge: Take a synchronous method that performs I/O operations and refactor it to use async/await.

  2. Parallel Processing: Write a method that downloads multiple files concurrently using Task.WhenAll.

  3. Timeout Implementation: Create a method that performs an async operation but times out after a specified period using CancellationTokenSource.

  4. Async Error Handling: Write a method that demonstrates proper exception handling in async code, including aggregate exceptions from Task.WhenAll.

  5. Cache Implementation: Implement a simple caching layer that uses async/await properly and avoids unnecessary async operations when data is cached.

By completing these exercises, you'll gain practical experience applying async best practices in real-world scenarios.



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)