Problem Context
Async/await is the most-used and least-understood feature in modern .NET. The mechanical view ("await means wait for the task") gets you a running app. The production view โ when continuations marshal back to a captured context, why async void kills processes, what ConfigureAwaitOptions.SuppressThrowing changes โ is what stops the 3am incidents.
This guide covers the patterns that survive load: configuring continuations correctly, fan-out with Task.WhenAll and the new .NET 9 Task.WhenEach, cancellation hygiene, and the common mistakes that cause deadlocks, thread-pool starvation, and unobserved exceptions.
- You sprinkle
.ConfigureAwait(false)everywhere because someone said you should โ but you can't articulate why - You've seen
.Resultor.Wait()deadlock production and don't fully trust async anymore - You fan out 1,000 HTTP calls with
Task.WhenAlland the host falls over - You want to process 50 downloads as they finish instead of waiting for all of them
Modern async patterns for .NET 9 โ including the new Task.WhenEach and ConfigureAwaitOptions APIs.
Concept Explanation
asyncdoesn't create a thread. It restructures your method into a state machine that registers a continuation when it hits await. Whoever completes the awaited task triggers that continuation โ usually on a thread-pool thread, sometimes on a captured SynchronizationContext (UI threads, classic ASP.NET). ASP.NET Core has no SynchronizationContext, which makes most of the old advice obsolete.
sequenceDiagram
participant C as Caller
participant M as async Method
participant IO as I/O (HTTP/DB)
participant TP as ThreadPool
C->>M: await DoAsync()
M->>IO: start request (no thread blocked)
Note over M,IO: โณ method suspends, thread freed
IO-->>TP: completion event
TP->>M: resume continuation on TP thread
M-->>C: return result
Two things to internalize: (1) await releases the calling threadโ that's the entire point. (2) A blocked await (.Result, .Wait()) holds a thread doing nothing until the task completes, which is fine until it's not (deadlock with a captured context, thread-pool starvation under load).
Implementation
Step 1: ConfigureAwait โ the modern story
// In ASP.NET Core / console / worker โ there is no SynchronizationContext.
// .ConfigureAwait(false) is a no-op. You don't need it.
public async Task<User> GetUserAsync(int id, CancellationToken ct)
{
var user = await db.Users.FindAsync([id], ct); // resumes on a TP thread either way
return user;
}
// In LIBRARY code that may be called from a UI app โ yes, use ConfigureAwait(false).
public async Task<string> ReadFileAsync(string path)
{
using var reader = new StreamReader(path);
return await reader.ReadToEndAsync().ConfigureAwait(false);
}
// .NET 8+ โ ConfigureAwaitOptions enum gives finer control
await SomeTask.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
// โ Awaits but does NOT throw on Faulted/Canceled. Useful for "fire and forget"
// where you want to observe the task (preventing UnobservedTaskException) without raising.
await CriticalTask.ConfigureAwait(
ConfigureAwaitOptions.ContinueOnCapturedContext | ConfigureAwaitOptions.ForceYielding);
// โ Forces the await to yield even if the task already completed.Stop spraying .ConfigureAwait(false)in app code. In ASP.NET Core there's nothing to capture, so it does nothing. Save it for shared libraries that genuinely need to be context-agnostic. The new ConfigureAwaitOptions enum (.NET 8) is the more interesting addition โ SuppressThrowing in particular is the cleanest way to safely "await and ignore" a task without try/catch boilerplate.
Step 2: Fan-out with Task.WhenAll and Task.WhenEach
// Classic โ wait for all, fail if any fail
public async Task<IReadOnlyList<string>> FetchAllAsync(IEnumerable<string> urls, CancellationToken ct)
{
var tasks = urls.Select(u => http.GetStringAsync(u, ct));
return await Task.WhenAll(tasks);
}
// .NET 9 โ process results AS THEY COMPLETE instead of waiting for all
public async Task ProcessFastestFirstAsync(IEnumerable<string> urls, CancellationToken ct)
{
var tasks = urls.Select(u => http.GetStringAsync(u, ct)).ToList();
await foreach (Task<string> finished in Task.WhenEach(tasks).WithCancellation(ct))
{
try
{
var body = await finished; // unwrap; throws if this task failed
await HandleAsync(body, ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "One URL failed; continuing");
}
}
}Before .NET 9 you had to roll your own loop with Task.WhenAny and remove finished tasks from a list. Task.WhenEachreturns an IAsyncEnumerable<Task> โ you just await foreach over completions in the order they happen. Use this whenever "I want to start showing results as they arrive" beats "I want them in input order".
Step 3: Bounded concurrency โ Parallel.ForEachAsync
// WRONG โ fires 10,000 simultaneous HTTP calls. Hits socket limits, server bans you,
// thread pool melts, observability tools light up.
await Task.WhenAll(items.Select(i => http.GetStringAsync(i.Url, ct)));
// RIGHT โ Parallel.ForEachAsync (.NET 6+) caps concurrency cleanly
var options = new ParallelOptions
{
MaxDegreeOfParallelism = 16,
CancellationToken = ct,
};
await Parallel.ForEachAsync(items, options, async (item, token) =>
{
var body = await http.GetStringAsync(item.Url, token);
await ProcessAsync(item, body, token);
});Task.WhenAll has no built-in throttle. For anything user-controlled in size, use Parallel.ForEachAsync with a sane MaxDegreeOfParallelism, or a SemaphoreSlim(maxConcurrency). Outbound HTTP and DB connections are the most common cause of cascading failures from unbounded fan-out.
Step 4: Cancellation hygiene
// Always accept and propagate CancellationToken โ including in private helpers
public async Task<Order> CreateOrderAsync(OrderInput input, CancellationToken ct)
{
var customer = await customers.FindAsync(input.CustomerId, ct);
var items = await inventory.ReserveAsync(input.LineItems, ct);
var order = new Order(customer, items);
await orders.AddAsync(order, ct);
return order;
}
// Combine timeouts with caller cancellation
public async Task<T> WithTimeoutAsync<T>(Func<CancellationToken, Task<T>> work,
TimeSpan timeout,
CancellationToken outer)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(outer);
cts.CancelAfter(timeout);
return await work(cts.Token);
}Pitfalls
1. async void outside event handlers. Exceptions are not observable by the caller โ they crash the process via UnobservedTaskException or kill the request. Always return Task or ValueTask. The single legitimate use is event handlers in UI/legacy frameworks.
2. .Result / .Wait() in app code. In any code that has a SynchronizationContext(older WinForms/WPF/classic ASP.NET), this is the textbook deadlock. In ASP.NET Core it doesn't deadlock but blocks a thread-pool thread, leading to starvation under load. Just await.
3. Forgetting to pass CancellationToken. A method that takes a CancellationToken but doesn't forward it to the calls inside is theatre. Audit with dotnet format analyzers --severity warn and the CA2016 rule.
4. Unbounded fan-out. Task.WhenAll(items.Select(...)) with a 10k-item list will exhaust connection pools. Use Parallel.ForEachAsync or a SemaphoreSlim. Production has limits; your code must too.
5. ValueTask misuse. A ValueTask can only be awaited once. Storing it in a field or awaiting twice is undefined behaviour. Use Task by default; reach for ValueTask only for hot paths where the synchronous-completion case dominates (e.g., cached lookups).
6. Mixing await and using. A using block scopes disposal to the synchronous extent. For async disposables (DbContext, Stream, HttpResponseMessage), use await using so disposal itself runs asynchronously.
Practical Takeaways
- Stop adding
.ConfigureAwait(false)in ASP.NET Core app code. Keep it in shared library code that may be called from UI apps. - Use the .NET 8
ConfigureAwaitOptions.SuppressThrowingto safely observe a "fire-and-forget" task without try/catch noise. - Use
Task.WhenEach(.NET 9) when you want to process results as they complete. UseTask.WhenAllwhen you need them all before continuing. - Bound your concurrency with
Parallel.ForEachAsyncorSemaphoreSlim. UnboundedTask.WhenAllis a failure waiting for traffic. - Accept and forward
CancellationTokenthrough every async path โ including timeouts viaCancellationTokenSource.CreateLinkedTokenSource. - Treat
async voidas banned outside event handlers. It silently swallows exceptions and crashes the process. - Prefer
await usingoverusingfor anyIAsyncDisposableโ DbContext, HttpResponseMessage, FileStream, etc.

