cates on every call. ValueTask<T> is a struct that avoids allocation when the operation completes synchronously or is cached.
- Use
Task for general-purpose async methods, especially when the method is likely to await or when the return value is stored.
- Use
ValueTask for hot paths where the method frequently completes synchronously (e.g., cache hits, in-memory lookups) or when implementing IAsyncEnumerable.
// Example: ValueTask for a cache-heavy repository
public ValueTask<User?> GetUserAsync(int id, CancellationToken ct)
{
if (_cache.TryGetValue(id, out var user))
{
// Returns synchronously; no Task allocation.
return new ValueTask<User?>(user);
}
// Falls back to async I/O; allocates Task via async state machine.
return GetUserFromDbAsync(id, ct);
}
private async Task<User?> GetUserFromDbAsync(int id, CancellationToken ct)
{
// Database call implementation
return await _db.QueryAsync<User>(id, ct);
}
2. ConfigurationAwait Strategy
ConfigureAwait(false) is critical for library code. It prevents the continuation from marshaling back to the captured synchronization context, reducing overhead and preventing deadlocks.
- Libraries: Always use
ConfigureAwait(false) on every await.
- Applications: Use
ConfigureAwait(false) for I/O operations that do not need to resume on the UI or request context. Omit it only when the continuation requires the context (e.g., updating UI elements in a desktop app or accessing HttpContext in ASP.NET Core, though ASP.NET Core has no sync context by default).
// Library method: Always configure await
public async Task<HttpResponseMessage> SendRequestAsync(HttpClient client, CancellationToken ct)
{
var response = await client.GetAsync("api/data", ct).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
// Continuation runs on thread pool, not captured context
var content = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
return response;
}
3. Cancellation Propagation
Cancellation is cooperative. Every async method must accept a CancellationToken and pass it down the call chain. This allows resources to be released immediately rather than waiting for timeouts.
- Signature: Include
CancellationToken ct = default as the last parameter.
- Propagation: Pass the token to all downstream async calls.
- Checkpoints: Use
ct.ThrowIfCancellationRequested() for long-running synchronous loops within async methods.
public async Task ProcessDataAsync(IEnumerable<Data> items, CancellationToken ct)
{
foreach (var item in items)
{
ct.ThrowIfCancellationRequested(); // Fast fail for synchronous work
await ProcessItemAsync(item, ct); // Pass token downstream
}
}
4. Composition Patterns
Avoid sequential awaits when operations are independent. Use Task.WhenAll to run operations concurrently. Avoid Task.WhenAny unless implementing timeout or fallback logic, as it requires careful handling of unawaited tasks to prevent resource leaks.
// Parallel execution of independent I/O operations
public async Task<DashboardData> GetDashboardAsync(CancellationToken ct)
{
var userTask = _userService.GetUserAsync(ct);
var statsTask = _statsService.GetStatsAsync(ct);
var feedTask = _feedService.GetFeedAsync(ct);
// All three requests start immediately; await completes when all finish
await Task.WhenAll(userTask, statsTask, feedTask).ConfigureAwait(false);
return new DashboardData(
User: userTask.Result,
Stats: statsTask.Result,
Feed: feedTask.Result
);
}
Pitfall Guide
Production experience reveals recurring patterns of async misuse that degrade system stability.
1. async void
Mistake: Using async void for methods other than event handlers.
Impact: The caller cannot await the method, making error handling impossible. Exceptions thrown in async void methods crash the process or are lost.
Best Practice: Always return Task or ValueTask. For fire-and-forget scenarios, use Task.Run or a dedicated background queue, and handle exceptions within the task.
2. Sync-over-Async
Mistake: Calling .Result, .Wait(), or .GetAwaiter().GetResult() on async methods.
Impact: Causes deadlocks in synchronization contexts. Blocks thread pool threads, leading to starvation.
Best Practice: Adopt "async all the way." If a sync entry point is unavoidable, use GetAwaiter().GetResult() to preserve exception details, but refactor to async as soon as possible.
3. Missing ConfigureAwait(false) in Libraries
Mistake: Omitting ConfigureAwait(false) in shared libraries.
Impact: Forces continuations back to the caller's context, causing performance degradation and potential deadlocks if the caller is on a UI thread or ASP.NET Classic context.
Best Practice: Enforce ConfigureAwait(false) in all library code via Roslyn analyzers.
4. Fire-and-Forget Without Context
Mistake: Awaiting a task without storing the result or handling exceptions, then discarding the task.
Impact: Exceptions are swallowed. Resources may not be cleaned up if the operation fails.
Best Practice: Use await for all operations. If truly fire-and-forget, attach a continuation to log exceptions:
var task = DoWorkAsync();
task.ContinueWith(t => Log.Error(t.Exception), TaskContinuation.OnlyOnFaulted);
5. Blocking the Thread Pool
Mistake: Performing CPU-bound work inside an async method without Task.Run.
Impact: The async method holds a thread pool thread while computing, preventing it from processing other async continuations.
Best Practice: Offload CPU-bound work to Task.Run to free the calling thread, or use Parallel.ForEach for bulk operations.
6. Improper Cancellation
Mistake: Ignoring the cancellation token or not passing it to downstream calls.
Impact: Operations continue running after the client disconnects, wasting resources and holding locks.
Best Practice: Propagate tokens everywhere. Use CancellationTokenSource with timeouts for external calls.
7. Awaiting Non-Awaited Tasks
Mistake: Calling an async method without await and ignoring the returned Task.
Impact: The method runs in the background, but exceptions are unobserved. The compiler may warn, but warnings are often suppressed.
Best Practice: Always await or explicitly discard the task with a comment if intentional: _ = DoWorkAsync();.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Library Method | ConfigureAwait(false) | Prevents context switch overhead and deadlocks. | Reduces latency and CPU usage. |
| Cache Hit / Sync Result | ValueTask<T> | Avoids heap allocation for completed tasks. | Reduces memory pressure and GC frequency. |
| Independent I/O Calls | Task.WhenAll | Executes operations concurrently. | Reduces total response time. |
| CPU-Bound Work | Task.Run | Frees thread pool for async continuations. | Prevents thread pool starvation. |
| Event Handler | async void | Required by event delegate signature. | N/A; unavoidable constraint. |
| Background Fire-and-Forget | Task.Run with logging | Ensures exceptions are captured. | Prevents silent failures. |
Configuration Template
Use the following .editorconfig and Roslyn analyzer rules to enforce async best practices automatically in your codebase.
.editorconfig
[*.cs]
# Enforce async all the way
dotnet_diagnostic.CA2007.severity = warning # Consider calling ConfigureAwait on the awaited task
# Enforce cancellation tokens
dotnet_diagnostic.CA1031.severity = none # Do not catch general exceptions (adjust based on policy)
dotnet_diagnostic.CA1062.severity = warning # Validate arguments
# Async return types
dotnet_style_readonly_struct = true:suggestion
Global Analyzer Suppression (if needed)
// GlobalUsings.cs or AssemblyInfo.cs
// Suppress CA2007 only in specific UI contexts where ConfigureAwait is not needed
[assembly: SuppressMessage("Usage", "CA2007:Consider calling ConfigureAwait", Justification = "UI Context requires sync context capture.")]
Quick Start Guide
- Initialize Analyzer Package: Add
Microsoft.CodeAnalysis.NetAnalyzers to your project file to enable async warnings.
- Refactor Signature: Update a target method to return
Task or ValueTask and add CancellationToken ct = default.
- Apply ConfigureAwait: Add
.ConfigureAwait(false) to all await expressions within the method.
- Propagate Token: Pass
ct to all downstream async calls.
- Verify: Run benchmarks and load tests to confirm latency and throughput improvements.
Implementing these practices transforms async code from a source of instability into a driver of performance and reliability. Consistent application of these patterns ensures your .NET applications scale efficiently under load while maintaining robust error handling and resource management.