C#
asynchronous programming
Task.Delay
performance optimization
.NET

using await Task.Delay in a for kills performance

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

Using await Task.Delay() inside a loop creates a pause on every iteration, which multiplies the total execution time by the number of iterations. A 100ms delay in a 1000-iteration loop adds 100 seconds of waiting. The performance impact comes from the timer overhead, context switches, and the accumulated delay time. The fix depends on your intent: for rate limiting, use SemaphoreSlim or Parallel.ForEachAsync with a degree of parallelism. For periodic polling, use PeriodicTimer. For batch processing with throttling, delay between batches rather than between individual items.

The Problem

csharp
1// BAD — 1000 iterations × 100ms = 100 seconds minimum
2async Task ProcessItemsSlow(List<Item> items)
3{
4    foreach (var item in items)
5    {
6        await ProcessAsync(item);
7        await Task.Delay(100);  // 100ms delay per item
8    }
9}

Each await Task.Delay(100) adds 100ms plus timer resolution overhead (~15ms on Windows). For 1000 items, the total delay alone is ~115 seconds.

Fix 1: Remove Unnecessary Delays

csharp
1// If the delay was for "being nice" to an API — use proper rate limiting instead
2async Task ProcessItemsFast(List<Item> items)
3{
4    foreach (var item in items)
5    {
6        await ProcessAsync(item);
7        // No delay — process as fast as the API allows
8    }
9}

If the delay was added to "avoid overloading" a service, remove it and let the service's built-in rate limiting or backpressure handle throttling.

Fix 2: Delay Between Batches, Not Items

csharp
1async Task ProcessInBatches(List<Item> items, int batchSize = 50)
2{
3    for (int i = 0; i < items.Count; i += batchSize)
4    {
5        var batch = items.Skip(i).Take(batchSize);
6
7        // Process entire batch concurrently
8        var tasks = batch.Select(item => ProcessAsync(item));
9        await Task.WhenAll(tasks);
10
11        // Single delay between batches (not per item)
12        if (i + batchSize < items.Count)
13        {
14            await Task.Delay(500);
15        }
16    }
17}
18
19// 1000 items / 50 per batch = 20 batches × 500ms = 10 seconds delay
20// vs 1000 × 100ms = 100 seconds with per-item delay

Batching reduces delay count from N to N/batchSize, dramatically cutting total wait time while still providing backpressure.

Fix 3: SemaphoreSlim for Concurrency Control

csharp
1async Task ProcessWithThrottle(List<Item> items, int maxConcurrency = 10)
2{
3    var semaphore = new SemaphoreSlim(maxConcurrency);
4
5    var tasks = items.Select(async item =>
6    {
7        await semaphore.WaitAsync();
8        try
9        {
10            await ProcessAsync(item);
11        }
12        finally
13        {
14            semaphore.Release();
15        }
16    });
17
18    await Task.WhenAll(tasks);
19}
20
21// All 1000 items process concurrently (up to 10 at a time)
22// No artificial delays — total time ≈ (1000 / 10) × single_item_time

SemaphoreSlim limits concurrency without artificial delays. Items process as fast as possible within the concurrency limit.

Fix 4: Parallel.ForEachAsync (.NET 6+)

csharp
1async Task ProcessParallel(List<Item> items)
2{
3    await Parallel.ForEachAsync(items,
4        new ParallelOptions { MaxDegreeOfParallelism = 10 },
5        async (item, ct) =>
6        {
7            await ProcessAsync(item);
8        });
9}

Parallel.ForEachAsync handles concurrency throttling internally. It is the cleanest solution in .NET 6+ for bounded concurrent async work.

Fix 5: PeriodicTimer for Polling

csharp
1// If the loop is for polling — use PeriodicTimer instead of Task.Delay
2async Task PollForUpdates(CancellationToken ct)
3{
4    using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
5
6    while (await timer.WaitForNextTickAsync(ct))
7    {
8        var updates = await CheckForUpdatesAsync();
9        if (updates.Any())
10        {
11            await ProcessUpdatesAsync(updates);
12        }
13    }
14}
15
16// Better than:
17// while (true)
18// {
19//     await CheckForUpdatesAsync();
20//     await Task.Delay(5000);  // Drifts over time
21// }

PeriodicTimer fires at consistent intervals regardless of how long the work takes, while Task.Delay adds drift (interval + processing time).

Fix 6: Channel-Based Producer-Consumer

csharp
1using System.Threading.Channels;
2
3async Task ProcessWithChannel(List<Item> items)
4{
5    var channel = Channel.CreateBounded<Item>(new BoundedChannelOptions(20)
6    {
7        FullMode = BoundedChannelFullMode.Wait
8    });
9
10    // Producer — adds items as fast as the channel allows
11    var producer = Task.Run(async () =>
12    {
13        foreach (var item in items)
14        {
15            await channel.Writer.WriteAsync(item);
16        }
17        channel.Writer.Complete();
18    });
19
20    // Consumers — process concurrently
21    var consumers = Enumerable.Range(0, 5).Select(async _ =>
22    {
23        await foreach (var item in channel.Reader.ReadAllAsync())
24        {
25            await ProcessAsync(item);
26        }
27    });
28
29    await Task.WhenAll(consumers.Append(producer));
30}

Channels provide natural backpressure without delays. The bounded capacity limits how far ahead the producer can get.

Common Pitfalls

  • Using Task.Delay for rate limiting: Task.Delay is a blunt instrument that adds fixed waits regardless of actual load. Use SemaphoreSlim or Parallel.ForEachAsync for proper concurrency control.
  • Timer resolution on Windows: Task.Delay(1) does not delay 1ms — the Windows timer resolution is ~15ms. Short delays are rounded up, making per-item delays even more expensive than expected.
  • Task.Delay(0) is not free: Task.Delay(0) yields to the scheduler but still has overhead from timer allocation and callback registration. Use Task.Yield() if you just want to yield.
  • Accumulated drift in polling loops: await Task.Delay(interval) after work creates drift — the actual interval is delay + processing time. Use PeriodicTimer for consistent intervals.
  • Delaying to "fix" a race condition: Adding Task.Delay(100) to work around timing issues is fragile. Use proper synchronization (SemaphoreSlim, lock, Channel) instead of relying on delays.

Summary

  • await Task.Delay in a loop multiplies total time by iteration count — avoid per-item delays
  • Batch items and delay between batches to reduce total delay by the batch size factor
  • Use SemaphoreSlim or Parallel.ForEachAsync for concurrency control without artificial delays
  • Use PeriodicTimer for polling instead of while (true) { ... await Task.Delay(); }
  • Use Channel<T> for producer-consumer patterns with natural backpressure
  • Only use Task.Delay when you genuinely need a time-based pause, not for rate limiting

Course illustration
Course illustration

All Rights Reserved.