Asynchronous programming in C# allows your applications to remain responsive while performing long-running operations like network requests, file I/O, or database queries. The async and await keywords make it straightforward to write asynchronous code that looks like synchronous code.

This guide covers the essential patterns and best practices for writing correct, efficient async code.

Understanding Async and Await

The async keyword marks a method as asynchronous. The await keyword pauses execution until an asynchronous operation completes:

public async Task<string> FetchDataAsync(string url)
{
    using var client = new HttpClient();
    
    // This line pauses until the HTTP request completes
    // but does not block the thread
    var response = await client.GetStringAsync(url);
    
    return response;
}

When you await an operation, the method returns control to the caller. The thread is free to do other work. When the operation completes, execution resumes from where it left off.

Async Method Return Types

Async methods can return several types:

// Returns a result
public async Task<int> GetCountAsync()
{
    var data = await LoadDataAsync();
    return data.Count;
}

// Returns no result but can be awaited
public async Task ProcessDataAsync()
{
    await SaveDataAsync();
    await NotifyUserAsync();
}

// Cannot be awaited, fire-and-forget
public async void OnButtonClick(object sender, EventArgs e)
{
    // Only use async void for event handlers
    await ProcessClickAsync();
}

// ValueTask for high-performance scenarios
public async ValueTask<int> GetCachedValueAsync(string key)
{
    if (_cache.TryGetValue(key, out int value))
    {
        return value; // Synchronous path, no allocation
    }
    
    return await LoadFromDatabaseAsync(key);
}

Best Practice: Async All the Way

Once you start using async, use it consistently throughout the call stack:

// Good: Async all the way
public async Task<OrderResult> ProcessOrderAsync(Order order)
{
    var inventory = await _inventoryService.CheckStockAsync(order.Items);
    var payment = await _paymentService.ProcessPaymentAsync(order.Payment);
    var shipment = await _shippingService.CreateShipmentAsync(order);
    
    return new OrderResult(inventory, payment, shipment);
}

// Controller using async
[HttpPost]
public async Task<IActionResult> CreateOrder(CreateOrderRequest request)
{
    var result = await _orderService.ProcessOrderAsync(request.ToOrder());
    return Ok(result);
}

Avoid mixing sync and async code:

// Bad: Blocking on async code
public OrderResult ProcessOrder(Order order)
{
    // This can cause deadlocks!
    var result = ProcessOrderAsync(order).Result;
    return result;
}

// Bad: Using .Wait()
public void SaveData(Data data)
{
    SaveDataAsync(data).Wait(); // Can deadlock
}

// Bad: Using GetAwaiter().GetResult()
public Data LoadData()
{
    return LoadDataAsync().GetAwaiter().GetResult(); // Can deadlock
}

Cancellation Tokens

Always support cancellation for long-running operations:

public async Task<List<Product>> SearchProductsAsync(
    string query,
    CancellationToken cancellationToken = default)
{
    var results = new List<Product>();
    
    using var client = new HttpClient();
    
    // Pass cancellation token to async operations
    var response = await client.GetAsync(
        $"https://api.example.com/search?q={query}",
        cancellationToken);
    
    response.EnsureSuccessStatusCode();
    
    var json = await response.Content.ReadAsStringAsync(cancellationToken);
    
    // Check for cancellation periodically in loops
    foreach (var item in ParseItems(json))
    {
        cancellationToken.ThrowIfCancellationRequested();
        results.Add(ProcessItem(item));
    }
    
    return results;
}

// Using CancellationTokenSource
public async Task PerformLongOperationAsync()
{
    using var cts = new CancellationTokenSource();
    
    // Cancel after 30 seconds
    cts.CancelAfter(TimeSpan.FromSeconds(30));
    
    try
    {
        await LongRunningOperationAsync(cts.Token);
    }
    catch (OperationCanceledException)
    {
        Console.WriteLine("Operation was cancelled");
    }
}

// Combining cancellation tokens
public async Task HandleRequestAsync(
    CancellationToken requestCancellation)
{
    using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMinutes(5));
    using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
        requestCancellation, timeoutCts.Token);
    
    await ProcessAsync(linkedCts.Token);
}

Parallel Async Operations

Run independent operations concurrently:

// Sequential: Slow
public async Task<DashboardData> GetDashboardDataSequentialAsync()
{
    var orders = await _orderService.GetRecentOrdersAsync();
    var customers = await _customerService.GetTopCustomersAsync();
    var products = await _productService.GetTopProductsAsync();
    var revenue = await _revenueService.GetMonthlyRevenueAsync();
    
    return new DashboardData(orders, customers, products, revenue);
}

// Parallel: Fast
public async Task<DashboardData> GetDashboardDataParallelAsync()
{
    // Start all tasks
    var ordersTask = _orderService.GetRecentOrdersAsync();
    var customersTask = _customerService.GetTopCustomersAsync();
    var productsTask = _productService.GetTopProductsAsync();
    var revenueTask = _revenueService.GetMonthlyRevenueAsync();
    
    // Wait for all to complete
    await Task.WhenAll(ordersTask, customersTask, productsTask, revenueTask);
    
    return new DashboardData(
        ordersTask.Result,
        customersTask.Result,
        productsTask.Result,
        revenueTask.Result);
}

// Using Task.WhenAll with results
public async Task<List<UserData>> GetUsersDataAsync(IEnumerable<int> userIds)
{
    var tasks = userIds.Select(id => GetUserDataAsync(id));
    var results = await Task.WhenAll(tasks);
    return results.ToList();
}

// Wait for first to complete
public async Task<string> GetFastestResponseAsync()
{
    var task1 = FetchFromServer1Async();
    var task2 = FetchFromServer2Async();
    var task3 = FetchFromServer3Async();
    
    var firstCompleted = await Task.WhenAny(task1, task2, task3);
    return await firstCompleted;
}

Exception Handling

Handle exceptions properly in async code:

public async Task ProcessBatchAsync(IEnumerable<Item> items)
{
    try
    {
        foreach (var item in items)
        {
            await ProcessItemAsync(item);
        }
    }
    catch (HttpRequestException ex)
    {
        _logger.LogError(ex, "Network error while processing batch");
        throw;
    }
    catch (OperationCanceledException)
    {
        _logger.LogWarning("Batch processing was cancelled");
        throw;
    }
}

// Handling exceptions from parallel tasks
public async Task ProcessAllItemsAsync(List<Item> items)
{
    var tasks = items.Select(ProcessItemAsync).ToList();
    
    try
    {
        await Task.WhenAll(tasks);
    }
    catch (Exception)
    {
        // Task.WhenAll throws only the first exception
        // Check each task for its exception
        var failedTasks = tasks.Where(t => t.IsFaulted);
        
        foreach (var task in failedTasks)
        {
            _logger.LogError(task.Exception, "Task failed");
        }
        
        throw;
    }
}

// Aggregate exception handling
public async Task ProcessWithAggregateExceptionAsync(List<Item> items)
{
    var tasks = items.Select(ProcessItemAsync).ToList();
    
    var allTask = Task.WhenAll(tasks);
    
    try
    {
        await allTask;
    }
    catch
    {
        // Access the AggregateException
        if (allTask.Exception is not null)
        {
            foreach (var ex in allTask.Exception.InnerExceptions)
            {
                _logger.LogError(ex, "Individual task failed");
            }
        }
        throw;
    }
}

ConfigureAwait

In library code, use ConfigureAwait(false) to avoid capturing the synchronization context:

// Library code
public async Task<byte[]> DownloadFileAsync(string url)
{
    using var client = new HttpClient();
    
    // Do not capture the synchronization context
    var response = await client.GetAsync(url).ConfigureAwait(false);
    response.EnsureSuccessStatusCode();
    
    return await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
}

// Application code (UI or ASP.NET Core)
// No need for ConfigureAwait in ASP.NET Core or modern UI frameworks
public async Task<IActionResult> Download(string url)
{
    var bytes = await _downloadService.DownloadFileAsync(url);
    return File(bytes, "application/octet-stream");
}

Async Streams

Use IAsyncEnumerable for streaming data:

public async IAsyncEnumerable<LogEntry> ReadLogsAsync(
    string logFile,
    [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
    using var reader = new StreamReader(logFile);
    
    while (!reader.EndOfStream)
    {
        cancellationToken.ThrowIfCancellationRequested();
        
        var line = await reader.ReadLineAsync(cancellationToken);
        
        if (line is not null)
        {
            yield return ParseLogEntry(line);
        }
    }
}

// Consuming async streams
public async Task ProcessLogsAsync(string logFile)
{
    await foreach (var entry in ReadLogsAsync(logFile))
    {
        await ProcessLogEntryAsync(entry);
    }
}

// With cancellation
public async Task ProcessLogsWithTimeoutAsync(string logFile)
{
    using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5));
    
    await foreach (var entry in ReadLogsAsync(logFile, cts.Token))
    {
        await ProcessLogEntryAsync(entry);
    }
}

// LINQ operations on async streams
public async Task ProcessErrorLogsAsync(string logFile)
{
    var errorLogs = ReadLogsAsync(logFile)
        .Where(entry => entry.Level == LogLevel.Error)
        .Take(100);
    
    await foreach (var entry in errorLogs)
    {
        await AlertOnErrorAsync(entry);
    }
}

Semaphore for Throttling

Limit concurrent async operations:

public class ThrottledProcessor
{
    private readonly SemaphoreSlim _semaphore;
    
    public ThrottledProcessor(int maxConcurrency)
    {
        _semaphore = new SemaphoreSlim(maxConcurrency);
    }
    
    public async Task ProcessAllAsync(IEnumerable<Item> items)
    {
        var tasks = items.Select(async item =>
        {
            await _semaphore.WaitAsync();
            try
            {
                await ProcessItemAsync(item);
            }
            finally
            {
                _semaphore.Release();
            }
        });
        
        await Task.WhenAll(tasks);
    }
    
    private async Task ProcessItemAsync(Item item)
    {
        await Task.Delay(100); // Simulate work
    }
}

// Usage
var processor = new ThrottledProcessor(maxConcurrency: 10);
await processor.ProcessAllAsync(items);

Async Lazy Initialization

Lazy initialization for async operations:

public class AsyncLazy<T>
{
    private readonly Lazy<Task<T>> _lazy;
    
    public AsyncLazy(Func<Task<T>> factory)
    {
        _lazy = new Lazy<Task<T>>(factory);
    }
    
    public Task<T> Value => _lazy.Value;
}

// Usage
public class ConfigurationService
{
    private readonly AsyncLazy<Configuration> _config;
    
    public ConfigurationService(IConfigurationLoader loader)
    {
        _config = new AsyncLazy<Configuration>(
            () => loader.LoadConfigurationAsync());
    }
    
    public async Task<string> GetSettingAsync(string key)
    {
        var config = await _config.Value;
        return config.GetSetting(key);
    }
}

Async Disposal

Implement IAsyncDisposable for async cleanup:

public class DatabaseConnection : IAsyncDisposable
{
    private SqlConnection? _connection;
    
    public async Task OpenAsync(string connectionString)
    {
        _connection = new SqlConnection(connectionString);
        await _connection.OpenAsync();
    }
    
    public async Task<int> ExecuteAsync(string sql)
    {
        if (_connection is null)
            throw new InvalidOperationException("Connection not open");
        
        using var command = new SqlCommand(sql, _connection);
        return await command.ExecuteNonQueryAsync();
    }
    
    public async ValueTask DisposeAsync()
    {
        if (_connection is not null)
        {
            await _connection.CloseAsync();
            await _connection.DisposeAsync();
            _connection = null;
        }
    }
}

// Usage
await using var connection = new DatabaseConnection();
await connection.OpenAsync(connectionString);
await connection.ExecuteAsync("INSERT INTO Logs ...");
// Connection is disposed asynchronously here

Common Pitfalls

Avoid async void

// Bad: Exception cannot be caught
public async void ProcessAsync()
{
    await DoWorkAsync(); // If this throws, it crashes
}

// Good: Returns Task
public async Task ProcessAsync()
{
    await DoWorkAsync();
}

Do not use .Result or .Wait()

// Bad: Can deadlock
var result = GetDataAsync().Result;

// Good: Await
var result = await GetDataAsync();

Do not forget to await

// Bad: Task is not awaited, exception is lost
public async Task ProcessAsync()
{
    SaveDataAsync(); // Fire and forget, no await!
}

// Good: Await the task
public async Task ProcessAsync()
{
    await SaveDataAsync();
}

Handle task cancellation

// Proper cancellation handling
public async Task<Data> FetchDataAsync(CancellationToken ct)
{
    try
    {
        return await _client.GetDataAsync(ct);
    }
    catch (OperationCanceledException) when (ct.IsCancellationRequested)
    {
        // Expected cancellation, handle gracefully
        _logger.LogInformation("Request was cancelled");
        return Data.Empty;
    }
}

Conclusion

Async and await make asynchronous programming in C# straightforward, but there are important patterns to follow for correct and efficient code.

Key takeaways:

  • Use async all the way through your call stack
  • Always support cancellation tokens
  • Run independent operations in parallel with Task.WhenAll
  • Use ConfigureAwait(false) in library code
  • Handle exceptions properly, especially from parallel tasks
  • Use IAsyncEnumerable for streaming scenarios
  • Implement IAsyncDisposable for async cleanup
  • Avoid async void except for event handlers
  • Never use .Result or .Wait() in async code

Following these patterns will help you write async code that is correct, performant, and maintainable.