Skip to Content
Course content

53: Handling Multiple Async Operations with Task.WhenAll

Click on the "Edit" button in the top corner of the screen to edit your slide content.

A few years ago, I was reviewing a PR for a teammate who was building a user dashboard. The page had to pull data from three different sources: the user's profile, their recent order history, and their notification settings. When I ran the page, it felt sluggish—almost a three-second delay before anything appeared. Looking at the code, I saw exactly why:

var profile = await _profileService.GetProfileAsync(userId);
var orders = await _orderService.GetRecentOrdersAsync(userId);
var settings = await _settingsService.GetSettingsAsync(userId);

They were awaiting each call sequentially. The code would start the profile request, stop everything and wait for it to finish, then start the orders request, wait again, and so on. Since these three calls didn't depend on each other, we were wasting a massive amount of time. I showed them how to use Task.WhenAll, and we got that load time down to the duration of the single slowest request. It's a simple change, but it's one of the most impactful performance wins you can implement in an async C# application.

Stopping the Sequential Wait

When you await a task immediately, you are telling the runtime, "Don't move to the next line of code until this specific operation is done." That's perfectly fine if the second operation needs a value from the first. But if you're just gathering data from a few independent sources, you're creating a bottleneck.

The trick is to start the tasks without awaiting them immediately. When you call an async method, it returns a Task object that represents the ongoing work. You can store these tasks in variables or a list, and they will all begin executing in the background. Once you have all your "promises" started, you use Task.WhenAll to pause execution until every single one of them has completed.

// Start all three tasks. They are now running concurrently.
Task<UserProfile> profileTask = _profileService.GetProfileAsync(userId);
Task<List<Order>> ordersTask = _orderService.GetRecentOrdersAsync(userId);
Task<UserSettings> settingsTask = _settingsService.GetSettingsAsync(userId);

// Now, wait for all of them to finish.
await Task.WhenAll(profileTask, ordersTask, settingsTask);

// Now it's safe to access the .Result or use await again (which will return immediately)
var profile = profileTask.Result;
var orders = ordersTask.Result;
var settings = settingsTask.Result;

Handling Results from Collections

In the example above, we had a fixed number of tasks. But what happens if you have a list of 50 IDs and you need to fetch data for all of them? You can't write 50 separate variables. Instead, you can project your list of IDs into a list of Tasks using LINQ.

I've seen developers try to use a foreach loop with an await inside it to solve this. Please, don't do that. That's just sequential execution in disguise. Instead, create a collection of tasks and pass that collection to WhenAll.

List<int> userIds = new() { 1, 2, 3, 4, 5 };

// Create a list of tasks. Note: we aren't awaiting inside the Select!
IEnumerable<Task<User>> downloadTasks = userIds.Select(id => _userService.GetUserAsync(id));

// Await the entire group.
User[] users = await Task.WhenAll(downloadTasks);

One detail I want you to notice here: Task.WhenAll is smart. If you give it a collection of Task<T>, it returns a single task that, when awaited, yields an array of T[] containing the results in the same order as the tasks you provided. It handles the bookkeeping for you.

The Nuance of Exceptions

Here is where things get a bit tricky. If one or more of the tasks passed to Task.WhenAll fails, the resulting task will be faulted. However, if you simply await Task.WhenAll(...), the runtime will only throw the exception from the first task that failed. The others are essentially swallowed by the await keyword.

If you need to know about every single failure—for instance, if you're logging errors for several different API calls—you'll need to examine the Exception property of the task returned by WhenAll. That property contains an AggregateException, which wraps all the individual failures into one object. I usually recommend a try-catch block around the WhenAll, and if you need the full details, casting the caught exception or checking the original tasks individually.




📋 Practical Task

Exercise: Concurrent Stock Price Aggregator

You are building a financial dashboard that needs to fetch the current price of several different stocks. Currently, the code fetches them one by one, which is too slow for a real-time dashboard.

Your Goal: Refactor the PriceAggregator class to fetch all stock prices concurrently using Task.WhenAll.

public class StockPriceService 
{
    // Simulates an API call that takes time
    public async Task<decimal> GetPriceAsync(string symbol) 
    {
        await Task.Delay(500); // Simulate network lag
        return new Random().Next(10, 500);
    }
}

public class PriceAggregator 
{
    private readonly StockPriceService _service = new();

    public async Task<Dictionary<string, decimal>> GetPortfolioPricesAsync(List<string> symbols) 
    {
        var results = new Dictionary<string, decimal>();
        
        // TODO: Refactor this loop to use Task.WhenAll 
        // Currently, this is sequential and slow!
        foreach (var symbol in symbols) 
        {
            decimal price = await _service.GetPriceAsync(symbol);
            results.Add(symbol, price);
        }

        return results;
    }
}

Requirements:

  • Modify GetPortfolioPricesAsync so that all GetPriceAsync calls start simultaneously.
  • Ensure the final dictionary is populated correctly with the symbols and their corresponding prices.
  • The total execution time for the method should be roughly 500ms, regardless of how many symbols are in the list.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.