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
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
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
Batching reduces delay count from N to N/batchSize, dramatically cutting total wait time while still providing backpressure.
Fix 3: SemaphoreSlim for Concurrency Control
SemaphoreSlim limits concurrency without artificial delays. Items process as fast as possible within the concurrency limit.
Fix 4: Parallel.ForEachAsync (.NET 6+)
Parallel.ForEachAsync handles concurrency throttling internally. It is the cleanest solution in .NET 6+ for bounded concurrent async work.
Fix 5: PeriodicTimer for Polling
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
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.Delayis a blunt instrument that adds fixed waits regardless of actual load. UseSemaphoreSlimorParallel.ForEachAsyncfor 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. UseTask.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. UsePeriodicTimerfor 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.Delayin 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
SemaphoreSlimorParallel.ForEachAsyncfor concurrency control without artificial delays - Use
PeriodicTimerfor polling instead ofwhile (true) { ... await Task.Delay(); } - Use
Channel<T>for producer-consumer patterns with natural backpressure - Only use
Task.Delaywhen you genuinely need a time-based pause, not for rate limiting

