Async/Await Best Practices in C#
Asynchronous programming in C# using async/await enables building responsive applications that don't block threads while waiting for I/O operations. Proper use of async/await is essential for creating scalable web services, responsive UIs, and efficient cloud-native applications. This post covers best practices for writing async code correctly.
Understanding Async/Await
The async/await pattern allows writing asynchronous code that looks and behaves like synchronous code, making it easier to read and maintain.
sequenceDiagram
participant Caller
participant AsyncMethod
participant Task
participant IOOperation
Caller->>AsyncMethod: Call async method
AsyncMethod->>IOOperation: Start I/O operation
AsyncMethod->>Task: Return Task
AsyncMethod-->>Caller: Return control (non-blocking)
Caller->>Caller: Continue execution
IOOperation->>Task: Complete
Task->>AsyncMethod: Resume execution
AsyncMethod->>Caller: Return result
Basic Async/Await Syntax
// Async method returning Task
public async Task ProcessDataAsync()
{
await Task.Delay(1000); // Simulates async work
Console.WriteLine("Processing complete");
}
// Async method returning Task<T>
public async Task<string> FetchDataAsync()
{
await Task.Delay(500);
return "Data fetched";
}
// Calling async methods
public async Task RunAsync()
{
await ProcessDataAsync();
string data = await FetchDataAsync();
Console.WriteLine(data);
}
Async All the Way
Once a method is async, all calling methods should be async to avoid blocking. Mixing sync and async code can lead to deadlocks.
// Bad: Blocking on async code
public void BadMethod()
{
var result = FetchDataAsync().Result; // Blocks thread - DON'T DO THIS
var result2 = FetchDataAsync().Wait(); // Also blocks - DON'T DO THIS
}
// Good: Async all the way
public async Task GoodMethodAsync()
{
var result = await FetchDataAsync(); // Non-blocking
}
// Good: Entry point for console apps
public static async Task Main(string[] args)
{
await RunApplicationAsync();
}
ConfigureAwait
ConfigureAwait(false) prevents capturing the synchronization context, improving performance in library code.
// In library code - use ConfigureAwait(false)
public async Task<User> GetUserAsync(int userId)
{
using var client = new HttpClient();
var response = await client.GetAsync($"api/users/{userId}")
.ConfigureAwait(false);
var content = await response.Content.ReadAsStringAsync()
.ConfigureAwait(false);
return JsonSerializer.Deserialize<User>(content);
}
// In UI code - DO NOT use ConfigureAwait(false)
private async void Button_Click(object sender, EventArgs e)
{
var user = await GetUserAsync(123);
// Must return to UI thread to update controls
txtName.Text = user.Name;
}
graph TD
A[Await Operation] --> B{ConfigureAwait?}
B -->|ConfigureAwait true / not specified| C[Capture Context]
B -->|ConfigureAwait false| D[Don't Capture Context]
C --> E[Resume on Original Context]
D --> F[Resume on Thread Pool]
Parallel Async Operations
Execute multiple async operations concurrently for better performance.
// Sequential execution (slow)
public async Task<Result> SequentialAsync()
{
var user = await GetUserAsync(1);
var orders = await GetOrdersAsync(1);
var products = await GetProductsAsync();
return new Result { User = user, Orders = orders, Products = products };
}
// Parallel execution (fast)
public async Task<Result> ParallelAsync()
{
var userTask = GetUserAsync(1);
var ordersTask = GetOrdersAsync(1);
var productsTask = GetProductsAsync();
await Task.WhenAll(userTask, ordersTask, productsTask);
return new Result
{
User = userTask.Result,
Orders = ordersTask.Result,
Products = productsTask.Result
};
}
// Parallel with Task.WhenAll
public async Task<User[]> GetMultipleUsersAsync(int[] userIds)
{
var tasks = userIds.Select(id => GetUserAsync(id));
return await Task.WhenAll(tasks);
}
// Wait for first completion
public async Task<string> GetFastestResponseAsync()
{
var task1 = CallApi1Async();
var task2 = CallApi2Async();
var task3 = CallApi3Async();
var completedTask = await Task.WhenAny(task1, task2, task3);
return await completedTask;
}
Exception Handling
Handle exceptions properly in async code.
// Basic exception handling
public async Task<User> GetUserSafeAsync(int userId)
{
try
{
return await GetUserAsync(userId);
}
catch (HttpRequestException ex)
{
// Log and handle
Console.WriteLine($"Request failed: {ex.Message}");
return null;
}
catch (Exception ex)
{
Console.WriteLine($"Unexpected error: {ex.Message}");
throw;
}
}
// Exception handling with WhenAll
public async Task ProcessMultipleAsync()
{
var tasks = new[]
{
ProcessItemAsync(1),
ProcessItemAsync(2),
ProcessItemAsync(3)
};
try
{
await Task.WhenAll(tasks);
}
catch (Exception)
{
// Check each task for exceptions
foreach (var task in tasks)
{
if (task.IsFaulted)
{
Console.WriteLine($"Task failed: {task.Exception.Message}");
}
}
}
}
// AggregateException handling
public async Task HandleAggregateExceptionsAsync()
{
try
{
await Task.WhenAll(
ThrowExceptionAsync("Error 1"),
ThrowExceptionAsync("Error 2")
);
}
catch (Exception ex)
{
if (ex is AggregateException aggEx)
{
foreach (var innerEx in aggEx.InnerExceptions)
{
Console.WriteLine($"Exception: {innerEx.Message}");
}
}
}
}
Cancellation Tokens
Implement cancellation for long-running async operations.
public async Task<Data> FetchDataAsync(CancellationToken cancellationToken)
{
using var client = new HttpClient();
var response = await client.GetAsync("api/data", cancellationToken);
// Check for cancellation
cancellationToken.ThrowIfCancellationRequested();
var content = await response.Content.ReadAsStringAsync(cancellationToken);
return JsonSerializer.Deserialize<Data>(content);
}
// Using cancellation token
public async Task ProcessWithTimeoutAsync()
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
try
{
var data = await FetchDataAsync(cts.Token);
Console.WriteLine("Data fetched successfully");
}
catch (OperationCanceledException)
{
Console.WriteLine("Operation timed out");
}
}
// Manual cancellation
public async Task CancellableOperationAsync()
{
var cts = new CancellationTokenSource();
// Cancel after some condition
Task.Run(async () =>
{
await Task.Delay(5000);
cts.Cancel();
});
try
{
await LongRunningOperationAsync(cts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("Operation cancelled");
}
}
// Cancellation with progress reporting
public async Task<int> ProcessItemsAsync(
IEnumerable<Item> items,
CancellationToken cancellationToken,
IProgress<int> progress = null)
{
int processed = 0;
foreach (var item in items)
{
cancellationToken.ThrowIfCancellationRequested();
await ProcessItemAsync(item);
processed++;
progress?.Report(processed);
}
return processed;
}
sequenceDiagram
participant Client
participant Operation
participant CancellationToken
Client->>CancellationToken: Create CTS
Client->>Operation: Start (pass token)
Operation->>Operation: Work...
Client->>CancellationToken: Cancel()
CancellationToken->>Operation: Signal cancellation
Operation->>Operation: Check IsCancellationRequested
Operation->>Client: Throw OperationCanceledException
Async Lazy Initialization
Implement thread-safe lazy initialization for async operations.
public class AsyncLazy<T>
{
private readonly Lazy<Task<T>> instance;
public AsyncLazy(Func<Task<T>> factory)
{
instance = new Lazy<Task<T>>(() => Task.Run(factory));
}
public Task<T> Value => instance.Value;
}
// Usage
public class DatabaseConnection
{
private readonly AsyncLazy<DbConnection> connection;
public DatabaseConnection(string connectionString)
{
connection = new AsyncLazy<DbConnection>(async () =>
{
var conn = new SqlConnection(connectionString);
await conn.OpenAsync();
return conn;
});
}
public Task<DbConnection> GetConnectionAsync() => connection.Value;
}
Async Lock Pattern
Implement async-friendly locking using SemaphoreSlim.
public class AsyncLock
{
private readonly SemaphoreSlim semaphore = new SemaphoreSlim(1, 1);
public async Task<IDisposable> LockAsync()
{
await semaphore.WaitAsync();
return new Releaser(semaphore);
}
private class Releaser : IDisposable
{
private readonly SemaphoreSlim semaphore;
public Releaser(SemaphoreSlim semaphore)
{
this.semaphore = semaphore;
}
public void Dispose()
{
semaphore.Release();
}
}
}
// Usage
public class ResourceManager
{
private readonly AsyncLock asyncLock = new AsyncLock();
private int counter = 0;
public async Task IncrementAsync()
{
using (await asyncLock.LockAsync())
{
counter++;
await Task.Delay(100); // Simulate work
}
}
}
Async Event Handlers
Handle async operations in event handlers safely.
// Bad: Async void event handler (no error handling)
private async void Button_Click(object sender, EventArgs e)
{
await ProcessDataAsync(); // Exceptions can crash the app
}
// Good: Async void with error handling
private async void Button_Click(object sender, EventArgs e)
{
try
{
await ProcessDataAsync();
}
catch (Exception ex)
{
MessageBox.Show($"Error: {ex.Message}");
}
}
// Better: Use async Task with error handling wrapper
private void Button_Click(object sender, EventArgs e)
{
_ = HandleClickAsync();
}
private async Task HandleClickAsync()
{
try
{
await ProcessDataAsync();
}
catch (Exception ex)
{
// Centralized error handling
HandleError(ex);
}
}
Throttling and Rate Limiting
Control the rate of async operations.
public class ThrottledProcessor
{
private readonly SemaphoreSlim semaphore;
public ThrottledProcessor(int maxConcurrency)
{
semaphore = new SemaphoreSlim(maxConcurrency, maxConcurrency);
}
public async Task<T> ProcessAsync<T>(Func<Task<T>> operation)
{
await semaphore.WaitAsync();
try
{
return await operation();
}
finally
{
semaphore.Release();
}
}
}
// Usage
public async Task ProcessManyItemsAsync()
{
var processor = new ThrottledProcessor(maxConcurrency: 5);
var items = Enumerable.Range(1, 100);
var tasks = items.Select(item =>
processor.ProcessAsync(() => ProcessItemAsync(item))
);
await Task.WhenAll(tasks);
}
Retry Logic
Implement retry patterns for transient failures.
public async Task<T> RetryAsync<T>(
Func<Task<T>> operation,
int maxRetries = 3,
TimeSpan? delay = null)
{
var retryDelay = delay ?? TimeSpan.FromSeconds(1);
for (int i = 0; i < maxRetries; i++)
{
try
{
return await operation();
}
catch (Exception ex) when (i < maxRetries - 1)
{
Console.WriteLine($"Attempt {i + 1} failed: {ex.Message}");
await Task.Delay(retryDelay);
retryDelay = TimeSpan.FromSeconds(retryDelay.TotalSeconds * 2); // Exponential backoff
}
}
// Last attempt without catching
return await operation();
}
// Usage
var data = await RetryAsync(async () =>
{
using var client = new HttpClient();
return await client.GetStringAsync("https://api.example.com/data");
}, maxRetries: 3);
Practical Example: Async Service Layer
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
public class ApiService
{
private readonly HttpClient httpClient;
private readonly SemaphoreSlim rateLimiter;
public ApiService(HttpClient httpClient, int maxConcurrentRequests = 10)
{
this.httpClient = httpClient;
this.rateLimiter = new SemaphoreSlim(maxConcurrentRequests, maxConcurrentRequests);
}
public async Task<T> GetAsync<T>(
string url,
CancellationToken cancellationToken = default)
{
await rateLimiter.WaitAsync(cancellationToken);
try
{
var response = await httpClient.GetAsync(url, cancellationToken)
.ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync(cancellationToken)
.ConfigureAwait(false);
return JsonSerializer.Deserialize<T>(content);
}
finally
{
rateLimiter.Release();
}
}
public async Task<IEnumerable<T>> GetManyAsync<T>(
IEnumerable<string> urls,
CancellationToken cancellationToken = default)
{
var tasks = urls.Select(url => GetAsync<T>(url, cancellationToken));
return await Task.WhenAll(tasks).ConfigureAwait(false);
}
public async Task<T> GetWithRetryAsync<T>(
string url,
int maxRetries = 3,
CancellationToken cancellationToken = default)
{
for (int i = 0; i < maxRetries; i++)
{
try
{
return await GetAsync<T>(url, cancellationToken);
}
catch (HttpRequestException) when (i < maxRetries - 1)
{
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, i)), cancellationToken)
.ConfigureAwait(false);
}
}
return await GetAsync<T>(url, cancellationToken);
}
}
Common Pitfalls to Avoid
// DON'T: Use async void except for event handlers
public async void BadMethodAsync() { } // Exceptions can't be caught
// DO: Use async Task
public async Task GoodMethodAsync() { }
// DON'T: Block on async code
var result = Task.Run(async () => await GetDataAsync()).Result;
// DO: Use async all the way
var result = await GetDataAsync();
// DON'T: Create unnecessary tasks
public async Task<int> BadAsync()
{
return await Task.Run(() => 42); // Unnecessary overhead
}
// DO: Return value directly
public Task<int> GoodAsync()
{
return Task.FromResult(42);
}
// DON'T: Forget to await
public async Task ForgetAwaitAsync()
{
GetDataAsync(); // Fire and forget - probably a bug
}
// DO: Always await or explicitly don't
public async Task RememberAwaitAsync()
{
await GetDataAsync();
}
Key Takeaways
- Use async/await for I/O-bound operations to avoid blocking threads
- Follow "async all the way" pattern to prevent deadlocks and thread pool starvation
- Use ConfigureAwait(false) in library code to avoid capturing synchronization context
- Execute independent async operations in parallel with Task.WhenAll for better performance
- Always handle exceptions in async methods, especially in async void event handlers
- Implement cancellation using CancellationToken for long-running operations
- Use SemaphoreSlim for async-friendly locking and rate limiting
- Implement retry logic with exponential backoff for transient failures
- Avoid async void except for event handlers
- Never block on async code using .Result or .Wait()
- Test async code thoroughly for race conditions and deadlocks
- Monitor task completion and handle exceptions in WhenAll scenarios