dotnet-csharp-async-patterns
Async/await best practices for .NET applications. Covers correct task usage, cancellation propagation, and the most common mistakes AI agents make when generating async code.
Scope
-
Async/await best practices and Task patterns
-
ConfigureAwait usage and SynchronizationContext
-
Cancellation token propagation
-
Common async agent pitfalls and fixes
Out of scope
-
Thread synchronization primitives (lock, SemaphoreSlim) -- see [skill:dotnet-csharp-concurrency-patterns]
-
Channel producer/consumer patterns -- see [skill:dotnet-channels]
-
BackgroundService registration and lifecycle -- see [skill:dotnet-background-services]
Cross-references: [skill:dotnet-csharp-dependency-injection] for IHostedService /BackgroundService registration, [skill:dotnet-csharp-coding-standards] for Async suffix naming, [skill:dotnet-csharp-modern-patterns] for language-level features.
Core Rules
Always Async All the Way
Every method in the async call chain must be async and await ed. Mixing sync and async causes deadlocks or thread pool starvation.
// Correct: async all the way public async Task<Order> GetOrderAsync(int id, CancellationToken ct = default) { var order = await _repo.GetByIdAsync(id, ct); return order; }
// WRONG: blocking on async -- causes deadlocks in ASP.NET and UI contexts public Order GetOrder(int id) { return _repo.GetByIdAsync(id).Result; // DEADLOCK RISK }
Prefer Task and ValueTask
Return Task or Task<T> by default. Use ValueTask<T> when the method frequently completes synchronously (cache hits, buffered I/O) to avoid Task allocation.
// ValueTask: frequently synchronous completion public ValueTask<User?> GetCachedUserAsync(int id, CancellationToken ct = default) { if (_cache.TryGetValue(id, out var user)) { return ValueTask.FromResult<User?>(user); }
return LoadUserAsync(id, ct);
}
private async ValueTask<User?> LoadUserAsync(int id, CancellationToken ct) { var user = await _repo.GetByIdAsync(id, ct); if (user is not null) { _cache[id] = user; }
return user;
}
ValueTask rules:
-
Never await a ValueTask more than once
-
Never use .Result or .GetAwaiter().GetResult() on an incomplete ValueTask
-
If you need to await multiple times or pass it around, convert with .AsTask()
Agent Gotchas
These are the most common async mistakes AI agents make when generating C# code.
- Blocking on Async (.Result , .Wait() , .GetAwaiter().GetResult() )
// WRONG -- all of these can deadlock var result = GetDataAsync().Result; GetDataAsync().Wait(); var result = GetDataAsync().GetAwaiter().GetResult();
// CORRECT var result = await GetDataAsync();
The only safe place for .GetAwaiter().GetResult() is in Main() pre-C# 7.1 or in rare infrastructure code where async is impossible (static constructors, Dispose() ).
- async void
async void methods cannot be awaited, and unhandled exceptions in them crash the process.
// WRONG -- fire-and-forget, unobserved exceptions async void ProcessOrder(Order order) { await _repo.SaveAsync(order); }
// CORRECT async Task ProcessOrderAsync(Order order) { await _repo.SaveAsync(order); }
The only valid use of async void is event handlers (WinForms, WPF, Blazor @onclick ), where the framework requires a void return type.
- Missing ConfigureAwait
In library code, use ConfigureAwait(false) to avoid capturing the synchronization context. In application code (ASP.NET Core, console apps), it is not needed because there is no synchronization context.
// Library code public async Task<byte[]> ReadFileAsync(string path, CancellationToken ct = default) { var bytes = await File.ReadAllBytesAsync(path, ct).ConfigureAwait(false); return bytes; }
// Application code (ASP.NET Core) -- ConfigureAwait not needed public async Task<IActionResult> GetOrder(int id, CancellationToken ct) { var order = await _service.GetOrderAsync(id, ct); return Ok(order); }
- Fire-and-Forget Without Error Handling
// WRONG -- exception is silently swallowed _ = SendEmailAsync(order);
// CORRECT -- use IHostedService or a background channel await _backgroundQueue.EnqueueAsync(ct => SendEmailAsync(order, ct));
If fire-and-forget is truly necessary, at minimum log the exception:
_ = Task.Run(async () => { try { await SendEmailAsync(order); } catch (Exception ex) { _logger.LogError(ex, "Failed to send email for order {OrderId}", order.Id); } });
- Forgetting CancellationToken
Always accept and forward CancellationToken . Never silently drop it.
// WRONG -- token not forwarded public async Task<List<Order>> GetAllAsync(CancellationToken ct = default) { return await _dbContext.Orders.ToListAsync(); // missing ct! }
// CORRECT public async Task<List<Order>> GetAllAsync(CancellationToken ct = default) { return await _dbContext.Orders.ToListAsync(ct); }
Cancellation Patterns
Creating Linked Tokens
Combine external cancellation with a timeout:
public async Task<Result> ProcessWithTimeoutAsync(CancellationToken ct = default) { using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(TimeSpan.FromSeconds(30));
return await DoWorkAsync(cts.Token);
}
Responding to Cancellation
public async Task ProcessBatchAsync(IEnumerable<Item> items, CancellationToken ct = default) { foreach (var item in items) { ct.ThrowIfCancellationRequested(); await ProcessItemAsync(item, ct); } }
Parallel Async
Task.WhenAll for Independent Operations
public async Task<Dashboard> LoadDashboardAsync(int userId, CancellationToken ct = default) { var ordersTask = _orderService.GetRecentAsync(userId, ct); var profileTask = _profileService.GetAsync(userId, ct); var statsTask = _statsService.GetAsync(userId, ct);
await Task.WhenAll(ordersTask, profileTask, statsTask);
return new Dashboard(ordersTask.Result, profileTask.Result, statsTask.Result);
}
Parallel.ForEachAsync (.NET 6+) for Bounded Parallelism
await Parallel.ForEachAsync(items, new ParallelOptions { MaxDegreeOfParallelism = 4, CancellationToken = ct }, async (item, token) => { await ProcessItemAsync(item, token); });
IAsyncEnumerable<T> Streaming
Use IAsyncEnumerable<T> for streaming results instead of buffering entire collections:
public async IAsyncEnumerable<Order> GetOrdersStreamAsync( [EnumeratorCancellation] CancellationToken ct = default) { await foreach (var order in _dbContext.Orders.AsAsyncEnumerable().WithCancellation(ct)) { yield return order; } }
Background Work
For background processing, use BackgroundService (or IHostedService ) instead of Task.Run or fire-and-forget patterns. See [skill:dotnet-csharp-dependency-injection] for registration patterns.
public sealed class OrderProcessorWorker( IServiceScopeFactory scopeFactory, ILogger<OrderProcessorWorker> logger) : BackgroundService { protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { using var scope = scopeFactory.CreateScope(); var processor = scope.ServiceProvider.GetRequiredService<IOrderProcessor>();
await processor.ProcessPendingAsync(stoppingToken);
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
}
}
}
Testing Async Code
[Fact] public async Task GetOrderAsync_WhenFound_ReturnsOrder() { // Arrange var repo = Substitute.For<IOrderRepository>(); repo.GetByIdAsync(42, Arg.Any<CancellationToken>()) .Returns(new Order { Id = 42 }); var service = new OrderService(repo);
// Act
var result = await service.GetOrderAsync(42);
// Assert
Assert.NotNull(result);
Assert.Equal(42, result.Id);
}
[Fact] public async Task ProcessAsync_WhenCancelled_ThrowsOperationCanceled() { using var cts = new CancellationTokenSource(); cts.Cancel();
await Assert.ThrowsAsync<OperationCanceledException>(
() => _service.ProcessAsync(cts.Token));
}
Knowledge Sources
Async patterns in this skill are grounded in publicly available content from:
-
Stephen Cleary's "Concurrency in C#" and Blog -- Definitive async best practices for .NET. Key guidance applied in this skill: "async all the way" (never block on async), "there is no thread" (async I/O does not consume a thread while waiting), correct CancellationToken propagation, async disposal via IAsyncDisposable, and BackgroundService patterns for long-running work. Source: https://blog.stephencleary.com/
-
David Fowler's Async Guidance -- Practical async anti-patterns and diagnostic scenarios for ASP.NET Core. Source: https://github.com/davidfowl/AspNetCoreDiagnosticScenarios/blob/master/AsyncGuidance.md
-
Stephen Toub's ConfigureAwait FAQ -- Canonical reference for ConfigureAwait behavior across application types. Source: https://devblogs.microsoft.com/dotnet/configureawait-faq/
Note: This skill applies publicly documented guidance. It does not represent or speak for the named sources.
References
-
Async/await best practices (David Fowler)
-
Stephen Cleary's Async Blog
-
Asynchronous programming patterns
-
Task-based asynchronous pattern (TAP)
-
ConfigureAwait FAQ
-
Framework Design Guidelines