C# Task Combinators: Task.WhenAll, Task.WhenAny, Task.WhenEach, and Parallel.ForEachAsync

2026/02/186 min read
bookmark this

Table of Contents

  1. Why Task Combinators Matter
  2. Task.WhenAll
  3. Start First, Await One-by-One
  4. Task.WhenAny
  5. Task.WhenEach (.NET 9)
  6. Parallel.ForEachAsync
  7. Exception Handling by Combinator
  8. Concurrent Scenario: Prefer task1, Fallback to task2
  9. Decision Guide

Why Task Combinators Matter

When an application performs multiple async operations, the hard part is usually not writing the operations themselves. The hard part is orchestration:

  • Do you need all results before continuing?
  • Do you only need the fastest result?
  • Do you want to process results as they complete?
  • Do you need bounded parallelism to protect CPU, I/O, or downstream services?

Task combinators solve these orchestration problems with expressive, reliable APIs.


Task.WhenAll

C# 5.0 / .NET 4.5

Purpose: Runs multiple tasks concurrently and completes when all of them finish.

  • Returns an array of results for Task<T> overloads.
Task<string> t1 = FetchAsync("url1");
Task<string> t2 = FetchAsync("url2");
Task<string> t3 = FetchAsync("url3");

string[] results = await Task.WhenAll(t1, t2, t3);
  • [OK] Use for fan-out / scatter-gather patterns.
  • [!] If one or more tasks fail, await Task.WhenAll(...) throws. Inspect individual tasks if you need full error details.

Start First, Await One-by-One

Another common pattern is to start multiple tasks first, then await them one-by-one:

Task task1 = DoTask1();
Task task2 = DoTask2();
Task task3 = DoTask3();

await task1;
await task2;
await task3;

This is concurrent in the success path because all tasks are created before the first await.

How it differs from Task.WhenAll:

  • Success case: often similar total time.
  • Failure case: stops at the first failed await, so later tasks might not be observed in that method.
  • Task.WhenAll: gives a single coordination point and naturally fits "wait for everything" intent.

If you need all tasks to complete and want consistent exception handling, prefer Task.WhenAll:

Task task1 = DoTask1();
Task task2 = DoTask2();
Task task3 = DoTask3();

try
{
    await Task.WhenAll(task1, task2, task3);
}
catch
{
    // Inspect all faults if needed.
    var failures = new[] { task1, task2, task3 }
        .Where(t => t.IsFaulted)
        .SelectMany(t => t.Exception!.Flatten().InnerExceptions)
        .ToList();

    throw failures.Count == 1
        ? failures[0]
        : new AggregateException(failures);
}

Task.WhenAny

C# 5.0 / .NET 4.5

Purpose: Completes when any of the supplied tasks finishes.

  • Returns the first completed task.
Task<string> fastest = await Task.WhenAny(
    FetchAsync("primary"),
    FetchAsync("fallback"));

string result = await fastest;
  • [OK] Use for racing, timeouts, or first-response-wins patterns.
  • [!] The other tasks keep running unless you cancel them.

Task.WhenEach (.NET 9)

.NET 9.0 / C# 13.0

Purpose: Returns an IAsyncEnumerable<Task<T>> that yields tasks in completion order.

Task<int>[] tasks = [ComputeAsync(1), ComputeAsync(2), ComputeAsync(3)];

await foreach (Task<int> completed in Task.WhenEach(tasks))
{
    int result = await completed;
    Console.WriteLine(result);
}
  • [OK] Process results as soon as each task finishes, without manual bookkeeping.

Parallel.ForEachAsync

.NET 6.0

Purpose: Processes items in parallel with async/await and built-in degree-of-parallelism control.

var urls = new[] { "url1", "url2", "url3" };

await Parallel.ForEachAsync(urls,
    new ParallelOptions { MaxDegreeOfParallelism = 3 },
    async (url, ct) =>
    {
        string html = await httpClient.GetStringAsync(url, ct);
        Process(html);
    });
  • [OK] Preferred over manual Task.WhenAll + Select when you need bounded parallelism.

Exception Handling by Combinator

Different combinators surface exceptions differently. Use the pattern that matches your intent.

Task.WhenAll: collect all failures

await Task.WhenAll(...) throws if any task fails. To inspect all faults, catch once, then inspect each task.

Task<string> t1 = FetchAsync("url1");
Task<string> t2 = FetchAsync("url2");
Task<string> t3 = FetchAsync("url3");

try
{
    string[] results = await Task.WhenAll(t1, t2, t3);
    return results;
}
catch
{
    var failures = new[] { t1, t2, t3 }
        .Where(t => t.IsFaulted)
        .SelectMany(t => t.Exception!.Flatten().InnerExceptions)
        .ToList();

    if (failures.Count == 1)
    {
        throw failures[0];
    }

    throw new AggregateException(failures);
}

Task.WhenAny: winner first, handle loser tasks explicitly

WhenAny only tells you which task completed first. The winner can still be faulted/canceled, and losers can fault later.

using var cts = new CancellationTokenSource();

Task<string> primary = FetchAsync("primary", cts.Token);
Task<string> fallback = FetchAsync("fallback", cts.Token);

Task<string> winner = await Task.WhenAny(primary, fallback);
Task<string> loser = winner == primary ? fallback : primary;

try
{
    string result = await winner; // May throw if winner faulted/canceled
    cts.Cancel();                 // Stop loser if possible
    return result;
}
catch (Exception winnerEx)
{
    // Winner failed: optionally try loser before failing the request.
    try
    {
        return await loser;
    }
    catch (Exception loserEx)
    {
        throw new AggregateException("Winner and loser both failed.", winnerEx, loserEx);
    }
}
finally
{
    // Observe loser completion to avoid hidden/unobserved failures.
    _ = loser.ContinueWith(_ => { }, TaskScheduler.Default);
}

Task.WhenEach: isolate per-item failures

WhenEach is best when each result is independent. Handle each completed task separately so one fault does not stop all processing.

Task<int>[] tasks = [ComputeAsync(1), ComputeAsync(2), ComputeAsync(3)];
var failures = new List<Exception>();

await foreach (Task<int> completed in Task.WhenEach(tasks))
{
    try
    {
        int value = await completed;
        Console.WriteLine($"Success: {value}");
    }
    catch (Exception ex)
    {
        failures.Add(ex);
        Console.WriteLine($"Failed: {ex.Message}");
    }
}

if (failures.Count > 0)
{
    throw new AggregateException("One or more computations failed.", failures);
}

Parallel.ForEachAsync: fail-fast by default, aggregate when needed

By default, an unhandled exception in one iteration stops the whole operation. Catch inside the loop if you want best-effort behavior.

var urls = new[] { "url1", "url2", "url3" };
var failures = new ConcurrentBag<Exception>();

await Parallel.ForEachAsync(urls,
    new ParallelOptions { MaxDegreeOfParallelism = 3 },
    async (url, ct) =>
    {
        try
        {
            string html = await httpClient.GetStringAsync(url, ct);
            Process(html);
        }
        catch (Exception ex)
        {
            failures.Add(new Exception($"{url} failed", ex));
        }
    });

if (!failures.IsEmpty)
{
    throw new AggregateException(failures);
}

Concurrent Scenario: Prefer task1, Fallback to task2

Requirement:

  • task1 and task2 must start in parallel.
  • If task1 completes with a non-null result, use it.
  • If task1 returns null, use task2.

Simple version

Task<string?> task1 = GetPrimaryAsync();
Task<string?> task2 = GetFallbackAsync();

// Both tasks are already running concurrently.
string? primary = await task1;

if (primary is not null)
{
    return primary;
}

return await task2;

Try/Catch Best Practices for This Scenario

  • Catch OperationCanceledException separately and rethrow with throw; when caller cancellation is requested.
  • If you catch broad Exception, do it only at the orchestration boundary, then inspect task states and rethrow meaningful exceptions.
  • Do not use throw ex; because it resets the original stack trace.
  • Prefer one boundary log entry in the catch block plus final structured failure details when mapping task outcomes.
  • Always observe both tasks (for example with Task.WhenAll) to avoid hidden or unobserved exceptions.
  • Keep business decision logic after the catch section: first choose successful result path, then map fault/cancel outcomes deterministically.

Use this variant when you need deterministic behavior across all outcomes and explicit exception semantics.


Decision Guide

  • Use Task.WhenAll when all operations are required.
  • Use Task.WhenAny when the first completed operation is enough.
  • Use Task.WhenEach when you want streaming completion-order processing.
  • Use Parallel.ForEachAsync when processing many inputs with bounded concurrency.
  • Use the primary/fallback pattern when business logic prefers one source but can accept another.

Task combinators are not just about speed. They are about expressing concurrency intent clearly, which makes code easier to reason about, test, and operate in production.