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