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.

๐Ÿค” Sound familiar?
  • You sprinkle .ConfigureAwait(false)everywhere because someone said you should โ€” but you can't articulate why
  • You've seen .Result or .Wait()deadlock production and don't fully trust async anymore
  • You fan out 1,000 HTTP calls with Task.WhenAll and 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.SuppressThrowing to 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. Use Task.WhenAll when you need them all before continuing.
  • Bound your concurrency with Parallel.ForEachAsync or SemaphoreSlim. Unbounded Task.WhenAll is a failure waiting for traffic.
  • Accept and forward CancellationToken through every async path โ€” including timeouts via CancellationTokenSource.CreateLinkedTokenSource.
  • Treat async void as banned outside event handlers. It silently swallows exceptions and crashes the process.
  • Prefer await using over using for any IAsyncDisposable โ€” DbContext, HttpResponseMessage, FileStream, etc.