Task.WhenAll waits for the whole team to finish. Task.WhenAny only cares who crosses the finish line first.
Sometimes you don't need every result — you need the first one. Maybe you're calling a payment gateway that should respond within two seconds, and if it doesn't, you'd rather show a "please try again" message than let the user stare at a spinner indefinitely. Or maybe you're querying two regional mirrors of the same data and you genuinely don't care which one answers — you just want whichever comes back first.
Task.WhenAll, from the previous lesson, can't help here — it insists on waiting for everything. In this lesson, you'll learn Task.WhenAny: how to race several tasks against each other and proceed as soon as the first one finishes, and the two real patterns this unlocks — enforcing a timeout, and taking the first successful response from redundant sources.
Task.WhenAny takes a collection of running Tasks and gives you back a Task that completes as soon as any one of them finishes — first past the post wins. The others are left running; WhenAny doesn't stop them, it just stops waiting for them.
public static Task<Task> WhenAny(IEnumerable<Task> tasks);
public static Task<Task> WhenAny(params Task[] tasks);
public static Task<Task<TResult>> WhenAny<TResult>(IEnumerable<Task<TResult>> tasks);
public static Task<Task<TResult>> WhenAny<TResult>(params Task<TResult>[] tasks);Notice the return type: Task<Task> — a Task of a Task. That's not a typo. Awaiting Task.WhenAny doesn't hand you the winning result directly — it hands you back the winning Task itself. You then have to await that one too, to actually get its result (or observe its exception, if it failed).
await Task.WhenAny(tasks) gives you "the task that finished first" — but that winning task might itself have completed successfully, faulted, or been canceled. Handing you the Task object (rather than just its raw result) lets you inspect exactly which of those happened, and handle each case, before deciding to unwrap its actual value with a second await.
Not every scenario needs every result. Consider enforcing a maximum wait time on a slow external API: you don't want to wait for the API call and a separate timeout — you want whichever happens first to decide the outcome. Task.WhenAll is the wrong tool entirely here; it would happily wait for both the real call and the timeout to both finish, which defeats the purpose.
What's needed is a way to start several tasks — the real operation, and something else that will complete on its own after a limit, or a genuine alternative source of the same data — and proceed the instant whichever one finishes first actually does, without waiting on the rest.
Task.WhenAny races the tasks you give it and resolves the instant the fastest one completes — success, failure, or cancellation, it doesn't matter which; "completed" is enough to win the race. Everything else about the losing tasks is left entirely up to you.
| WhenAll | WhenAny |
|---|---|
| Waits for every task to complete | Waits for the first task to complete |
| Returns all results, in input order | Returns the winning Task itself — you await it separately for its result |
| Total time ≈ the slowest task | Total time ≈ the fastest task |
| Use for: "I need everything before I can proceed" | Use for: "I need whichever comes back first" |
| Loser tasks don't matter — all succeeded eventually | Loser tasks keep running in the background unless you cancel them yourself |
Task<string> realCallTask = httpClient.GetStringAsync(url);
Task timeoutTask = Task.Delay(TimeSpan.FromSeconds(2));
Task.Delay returns a Task that simply completes after the given duration — it doesn't do any real work, it's purely a clock.Task winner = await Task.WhenAny(realCallTask, timeoutTask);
if (winner == timeoutTask)
{
throw new TimeoutException("The call took too long.");
}
string result = await realCallTask; // already completed — this returns immediately
Putting the timeout pattern together into a small, reusable method:
public async Task<string> GetWithTimeoutAsync(string url, TimeSpan timeout)
{
Task<string> callTask = httpClient.GetStringAsync(url);
Task delayTask = Task.Delay(timeout);
Task winner = await Task.WhenAny(callTask, delayTask);
if (winner == delayTask)
{
throw new TimeoutException($"Request to {url} exceeded {timeout}.");
}
return await callTask; // the call won — safe to read its result
}Walking through it:
callTask and delayTask start immediately and run concurrently — same "start before awaiting" principle from the WhenAll lesson.winner; reference equality (==) is how you tell which one it was.callTask again just returns its already-computed result instantly — no additional wait.A pricing service that queries two regional mirrors of the same exchange-rate API and uses whichever responds first — a common "redundant sources, first response wins" pattern for latency-sensitive lookups:
public async Task<decimal> GetExchangeRateAsync(string currencyPair, CancellationToken ct)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
Task<decimal> primaryTask = _primaryRegionClient.GetRateAsync(currencyPair, cts.Token);
Task<decimal> secondaryTask = _secondaryRegionClient.GetRateAsync(currencyPair, cts.Token);
Task<decimal> winner = await Task.WhenAny(primaryTask, secondaryTask);
cts.Cancel(); // stop whichever mirror is still in flight — we no longer need it
return await winner;
}Both regional calls start at the same time. Whichever mirror answers first "wins," and its result is returned. Critically, this example calls cts.Cancel() on a shared, linked token the instant a winner is known — telling the still-running loser to stop, rather than letting it burn bandwidth and server resources for a result nobody will use. That's the responsible way to use Task.WhenAny: losing tasks don't stop on their own.
You need a ride urgently, so you call two different taxi companies at the same time and tell each "send a car." Whichever car actually pulls up to your curb first is the one you get in — you don't wait for the second car to arrive too. That's Task.WhenAny: multiple requests in flight, and you act the instant the first one comes through.
But here's the part people forget: the other taxi company doesn't know you've already left. Unless you call them back and cancel, their driver is still on the way, burning gas, for a fare that's no longer needed. That's exactly why real Task.WhenAny code should explicitly cancel the losing task once a winner is known — nobody does that automatically for you.
Like Task.WhenAll, Task.WhenAny doesn't start anything and doesn't use extra threads on its own. It registers a lightweight continuation on every task you pass in; the very first one to report completion causes the combined Task<Task> to complete, carrying a reference to whichever task just finished. The other tasks aren't touched in any way by this — they keep executing exactly as if WhenAny had never been called, which is precisely why explicit cancellation of the losers is your responsibility, not the runtime's.
This is the single most important thing to internalize about Task.WhenAny. It only stops waiting — it never stops the losing tasks from actually running. If you don't explicitly pass a shared CancellationToken into every candidate task and cancel it once a winner is known, every "losing" operation keeps consuming resources — network connections, database connections, memory — until it finishes (or fails) entirely on its own, completely unobserved.
"First to complete" includes a task that finishes by faulting. If the fastest of three calls happens to be the one that immediately throws an exception, that faulted task is your "winner" — awaiting it will rethrow that exception. Don't assume the winner necessarily has a usable result; check its status, or simply await it inside a try/catch, before trusting its output.
Wrong — the timeout "wins," but the real HTTP call keeps running in the background forever, unobserved:
Task winner = await Task.WhenAny(callTask, Task.Delay(timeout));
if (winner != callTask) throw new TimeoutException();
// callTask is still out there running — nothing ever cancels itCorrect — pass a shared token into the real call, so a timeout can actually cancel it, not just stop waiting on it:
using var cts = new CancellationTokenSource();
Task<string> callTask = httpClient.GetStringAsync(url, cts.Token);
Task delayTask = Task.Delay(timeout);
Task winner = await Task.WhenAny(callTask, delayTask);
if (winner == delayTask)
{
cts.Cancel(); // actually stop the in-flight call
throw new TimeoutException();
} var result = await Task.WhenAny(taskA, taskB); — this compiles, but result is a Task<T>, not a T. Using it as if it were the value directly is a type mismatch waiting to happen (or, worse, a confusing runtime surprise if you store it loosely typed).
Await twice: once for WhenAny to learn who won, once more on the winner itself to unwrap the actual value: var result = await await Task.WhenAny(taskA, taskB); (or, more readably, split across two lines as shown in earlier examples).
Using Task.WhenAny in a loop to "process everything as it finishes" without realizing you're throwing away every result except the very first — the rest just keep running, forgotten.
If every result actually matters, that's Task.WhenAll's job (previous lesson), not Task.WhenAny's.
CancellationTokenSource(TimeSpan) (from the previous lesson) is often more direct than manually racing against Task.Delay. Task.WhenAny shines when you specifically need to inspect which of several tasks actually won — not just enforce a cutoff.
Task.WhenAny returns as soon as the first of several running Tasks completes — success, failure, or cancellation all count as "completed."Task itself, not the raw result — you must await it a second time to get the value.Task.Delay for a timeout, and querying redundant sources for whichever answers first.CancellationToken and cancel it explicitly once a winner is known.You've seen how Task.WhenAny races tasks and returns the first one to finish. Let's check the details that trip people up most.
1. What does await Task.WhenAny(taskA, taskB) actually give you?
Correct: B
Why B is correct: Task.WhenAny returns Task<Task> (or Task<Task<TResult>>) — awaiting it gives you back the winning Task itself, which you then await again to get its actual result or observe its exception.
Why A is incorrect: That's a common assumption, but it's wrong — you get the Task wrapper, not the unwrapped value, requiring a second await.
Why C is incorrect: That describes Task.WhenAll's generic overload, which is a fundamentally different method with different semantics.
Why D is incorrect: WhenAny doesn't return a boolean — it returns the actual winning Task object, letting you inspect or compare it.
Reinforcement: Remember the "Task of a Task" shape — it's the most distinctive, easy-to-forget detail of WhenAny.
2. In the timeout pattern (racing a real call against Task.Delay), what happens to the real call if the Task.Delay task wins the race?
Correct: B
Why B is correct: Task.WhenAny only stops waiting for the losing tasks — it does nothing to actually stop them. Unless you passed a CancellationToken into the real call and explicitly cancel it once you know the delay won, it keeps running to completion (or failure) on its own, unobserved.
Why A is incorrect: There is no automatic cancellation — this is precisely the trap this lesson warns about.
Why C is incorrect: The real call has no idea a timeout occurred unless your code explicitly cancels it — it will run to its natural conclusion.
Why D is incorrect: There's no such pausing mechanism — tasks aren't controlled by WhenAny once they've been passed to it.
Reinforcement: Always pass a shared CancellationToken and cancel it explicitly once a winner is known — WhenAny never does this for you.
3. Of three tasks passed to Task.WhenAny, the fastest one to complete does so by throwing an exception. What happens?
Correct: B
Why B is correct: "First to complete" includes completing by faulting — a faulted task is still a completed task. It becomes the winner, and awaiting it a second time surfaces its exception exactly as awaiting any faulted Task normally would.
Why A is incorrect: WhenAny has no special handling for faulted tasks — completion is completion, regardless of how it happened.
Why C is incorrect: An exception inside a Task doesn't crash the process on its own — it's captured on the Task and only surfaces when something awaits (or otherwise observes) that Task.
Why D is incorrect: Nothing is silently converted — the exception is preserved and rethrown when you await the winning task.
Reinforcement: Don't assume the winner of a WhenAny race necessarily succeeded — check its outcome (or await it inside a try/catch) before trusting its result.
4. You want to process the results of five independent tasks as each one finishes, keeping every result. Is Task.WhenAny the right tool?
Correct: B
Why B is correct: A single Task.WhenAny call only ever surfaces one winner. To react to each of five tasks as it finishes while keeping every result, you'd need to repeatedly call WhenAny in a loop (removing the winner from the candidate set each time) — or, if you don't need per-completion reactions and just want everything eventually, Task.WhenAll is the simpler, correct tool.
Why A is incorrect: A single WhenAny call surfaces exactly one winner, not all five in order.
Why C is incorrect: WhenAny accepts any number of tasks, not just two.
Why D is incorrect: WhenAny and WhenAll have fundamentally different completion semantics — one is not simply a reordering of the other.
Reinforcement: Reach for WhenAny specifically when you only care about the first result — for "give me everything," WhenAll remains the right choice.
You now know how to race tasks against each other and handle the winner correctly — including cancelling the losers. Next up: a closer, more precise look at how exceptions actually propagate through async code, including the WhenAll/WhenAny cases you just saw.
dotnetmadeeasy.com — Learn C# and .NET, the right way.