DEV Community

Dhana
Dhana

Posted on

Why Await Inside a Loop Can Make Your Async Code 5x Slower

Here is a mistake that compiles, runs, returns correct results, and quietly makes your code about five times slower than it needs to be: calling several independent async methods inside a loop with await on each one.

I measured it. Five one-second calls took about five seconds in a loop and about one second with Task.WhenAll. This article shows the numbers, explains why, and covers the four things that go wrong when you simply "run everything at once".

The Experiment

Task.Delay(1000) stands in for a database or web call, so each call "takes" one second without using the CPU:

using System.Diagnostics;

async Task<int> FakeApiCall(int id)
{
    await Task.Delay(1000);   // simulates waiting on a database or web service
    return id;
}

var sw = Stopwatch.StartNew();
for (int i = 1; i <= 5; i++)
{
    await FakeApiCall(i);
}
Console.WriteLine($"5 calls, one after another:   {sw.ElapsedMilliseconds} ms");

sw.Restart();
await Task.WhenAll(Enumerable.Range(1, 5).Select(FakeApiCall));
Console.WriteLine($"5 calls, Task.WhenAll:        {sw.ElapsedMilliseconds} ms");

sw.Restart();
await Task.WhenAll(Enumerable.Range(1, 100).Select(_ => Task.Delay(1000)));
Console.WriteLine($"100 x Task.Delay(1000):       {sw.ElapsedMilliseconds} ms");
Enter fullscreen mode Exit fullscreen mode

The Results

I ran this twice in a Release build on .NET 8 on one laptop.

Test Run 1 Run 2
5 calls, one after another 5,102 ms 5,041 ms
5 calls, Task.WhenAll 1,035 ms 1,030 ms
100 × Task.Delay(1000) with Task.WhenAll 1,016 ms 1,002 ms

The WhenAll version was roughly 4.9 times faster. Each version also finished a little above its ideal time, because starting and finishing tasks has a small cost. Two runs are enough to see the pattern, but they are not enough to trust the last few milliseconds.

Why the Loop Is Slow

await pauses the current method until that call finishes. Inside a loop, that means call 2 doesn't start until call 1 is done, call 3 waits for call 2, and so on, so the waits add up: 1 + 1 + 1 + 1 + 1 = 5 seconds.

Task.WhenAll works differently. Select(FakeApiCall) starts all five calls straight away, each returning a task that is still running. WhenAll then waits for the slowest one. The waits overlap, so the total is about the longest single wait, not the sum.

Waiting Is Cheap

The third test is the surprising one. One hundred waits finished in about the same time as five. Adding more waiting tasks added almost no time.

The reason is that a real await on a database or network call doesn't keep a thread busy while it waits. I measured only elapsed time here, not thread counts, so I won't claim a number, but the timing is consistent with that behavior. If those hundred waits had run one after another, the same arithmetic gives about 100 seconds. I didn't run that version.

Four Things That Go Wrong With "Run Everything at Once"

1. The service on the other end has limits

One hundred simultaneous calls to a real API or database can overwhelm it, or exhaust a connection pool. Limit how many run at once, for example with SemaphoreSlim:

var gate = new SemaphoreSlim(10);   // at most 10 calls in flight

async Task<int> ThrottledCall(int id)
{
    await gate.WaitAsync();
    try { return await FakeApiCall(id); }
    finally { gate.Release(); }
}

int[] results = await Task.WhenAll(Enumerable.Range(1, 100).Select(ThrottledCall));
Enter fullscreen mode Exit fullscreen mode

Or with Parallel.ForEachAsync, available since .NET 6:

await Parallel.ForEachAsync(Enumerable.Range(1, 100),
    new ParallelOptions { MaxDegreeOfParallelism = 10 },
    async (id, ct) => { await FakeApiCall(id); });
Enter fullscreen mode Exit fullscreen mode

With ten at a time and one-second calls, I'd expect about ten seconds for a hundred calls, but I haven't measured either version.

2. Only the first exception reaches your catch

When several tasks fail, await Task.WhenAll(...) rethrows just the first exception. The rest are not lost, but they sit on the combined task:

var all = Task.WhenAll(tasks);
try
{
    await all;
}
catch
{
    foreach (var ex in all.Exception!.InnerExceptions)
        Console.WriteLine(ex.Message);   // every failure, not only the first
}
Enter fullscreen mode Exit fullscreen mode

3. One database connection can't serve concurrent calls

In general, a database connection handles one command at a time. If you start several database calls at once, each needs its own connection (and its own transaction if you use one). Sharing a single connection across them is the same shared-state mistake that loses updates in multithreaded code.

4. Some calls depend on each other

If call 2 needs the result of call 1, they can't overlap, and the plain await order is correct. WhenAll is for independent work.

Results Keep Their Order

Task.WhenAll returns results in the same order as the tasks you passed in, even if they finish in a different order. In the examples above, results[0] belongs to the first call.

This Is the Waiting Half, Not the CPU Half

Everything here applies to code that spends its time waiting. If the work is CPU-bound, such as heavy calculation, async doesn't make it faster, and you're into the territory of my earlier article on parallel loops, where a plain sequential loop beat every parallel version for tiny work.

A Decision Rule

Situation Use
Calls depend on each other Plain await in order
Independent calls, a few of them Task.WhenAll
Independent calls, many of them Task.WhenAll with a throttle (SemaphoreSlim or Parallel.ForEachAsync)
Heavy calculation Parallel code, and measure against sequential first

Check Your Own Code

Search your project for await inside for and foreach loops. For each one, ask whether the iterations really depend on each other. If they don't, time the loop with a Stopwatch, change it to Task.WhenAll, and time it again in Release mode. Run it more than once, since a single timing can mislead.

Takeaway

An await inside a loop runs independent calls one after another, so the waits add up. Starting the calls first and awaiting them together with Task.WhenAll made five one-second calls take about one second instead of about five. The speed-up comes with conditions: limit how many calls run at once, expect only the first exception to reach your catch, give each database call its own connection, and keep dependent calls in order.

Top comments (0)