Introduction
Invoking async delegates inside loops in C# requires careful handling of closures, concurrency, and exception propagation. The common pattern is iterating over a collection and calling an async delegate (like Func<T, Task>) for each item. The main decision is whether to run delegates sequentially (one at a time) or concurrently (all at once). Getting this wrong causes bugs like captured loop variables, swallowed exceptions, and race conditions. This article covers both patterns with correct implementations.
The Problem: Async Delegate in a Loop
1public async Task ProcessItems(
2 IEnumerable<string> items,
3 Func<string, Task> processAsync)
4{
5 // Sequential — each item waits for the previous one
6 foreach (var item in items)
7 {
8 await processAsync(item);
9 }
10}
This is the simplest correct pattern. foreach with await runs each delegate invocation sequentially. The loop does not advance until the current Task completes.
Sequential Execution
1public async Task ProcessSequentially(
2 IList<Order> orders,
3 Func<Order, Task> handleOrder)
4{
5 foreach (var order in orders)
6 {
7 await handleOrder(order);
8 Console.WriteLine($"Processed order {order.Id}");
9 }
10 Console.WriteLine("All orders processed");
11}
12
13// Usage
14await ProcessSequentially(orders, async order =>
15{
16 await _repository.SaveAsync(order);
17 await _notificationService.SendAsync(order.CustomerId);
18});
Sequential execution is correct when order matters (database inserts with dependencies) or when the downstream service cannot handle concurrent requests.
Concurrent Execution with Task.WhenAll
1public async Task ProcessConcurrently(
2 IList<Order> orders,
3 Func<Order, Task> handleOrder)
4{
5 var tasks = orders.Select(order => handleOrder(order));
6 await Task.WhenAll(tasks);
7 Console.WriteLine("All orders processed");
8}
9
10// With results
11public async Task<IList<Result>> ProcessWithResults(
12 IList<Order> orders,
13 Func<Order, Task<Result>> handleOrder)
14{
15 var tasks = orders.Select(order => handleOrder(order)).ToList();
16 var results = await Task.WhenAll(tasks);
17 return results.ToList();
18}
Task.WhenAll starts all delegates immediately and waits for all to complete. This is faster when operations are independent (API calls, file uploads).
Controlling Concurrency with SemaphoreSlim
1public async Task ProcessWithThrottle(
2 IList<string> urls,
3 Func<string, Task> downloadAsync,
4 int maxConcurrency = 5)
5{
6 var semaphore = new SemaphoreSlim(maxConcurrency);
7
8 var tasks = urls.Select(async url =>
9 {
10 await semaphore.WaitAsync();
11 try
12 {
13 await downloadAsync(url);
14 }
15 finally
16 {
17 semaphore.Release();
18 }
19 });
20
21 await Task.WhenAll(tasks);
22}
23
24// Usage — at most 5 concurrent downloads
25await ProcessWithThrottle(urls, async url =>
26{
27 using var client = new HttpClient();
28 var content = await client.GetStringAsync(url);
29 await File.WriteAllTextAsync($"{Guid.NewGuid()}.html", content);
30}, maxConcurrency: 5);
SemaphoreSlim limits how many delegates run at the same time, preventing resource exhaustion when processing large collections.
Closure Bug with Loop Variables
1// BUG in older C# (pre-C# 5) with for loops
2var tasks = new List<Task>();
3for (int i = 0; i < items.Count; i++)
4{
5 // BUG: i is captured by reference — all tasks see the final value
6 tasks.Add(processAsync(items[i])); // This is fine — items[i] evaluated immediately
7
8 // BUG example with a closure capturing i:
9 tasks.Add(Task.Run(async () =>
10 {
11 await Task.Delay(100);
12 Console.WriteLine(i); // Prints items.Count for all iterations
13 }));
14}
15
16// Fix: capture in a local variable
17for (int i = 0; i < items.Count; i++)
18{
19 var index = i; // Captured per iteration
20 tasks.Add(Task.Run(async () =>
21 {
22 await Task.Delay(100);
23 Console.WriteLine(index); // Correct value
24 }));
25}
26
27// Best: use foreach — loop variable captured correctly in C# 5+
28foreach (var item in items)
29{
30 tasks.Add(processAsync(item)); // item captured correctly
31}
In C# 5+, foreach captures the loop variable per iteration. With for loops, the index variable is shared across iterations and must be copied to a local variable.
Exception Handling
1// Sequential — exceptions propagate naturally
2public async Task ProcessWithErrorHandling(
3 IList<Order> orders,
4 Func<Order, Task> handleOrder)
5{
6 var errors = new List<Exception>();
7
8 foreach (var order in orders)
9 {
10 try
11 {
12 await handleOrder(order);
13 }
14 catch (Exception ex)
15 {
16 errors.Add(ex);
17 // Continue processing remaining orders
18 }
19 }
20
21 if (errors.Any())
22 {
23 throw new AggregateException("Some orders failed", errors);
24 }
25}
26
27// Concurrent — Task.WhenAll collects all exceptions
28public async Task ProcessConcurrentWithErrors(
29 IList<Order> orders,
30 Func<Order, Task> handleOrder)
31{
32 var tasks = orders.Select(order => handleOrder(order)).ToList();
33
34 try
35 {
36 await Task.WhenAll(tasks);
37 }
38 catch
39 {
40 // Task.WhenAll throws only the first exception
41 // Check each task for individual exceptions
42 var failures = tasks
43 .Where(t => t.IsFaulted)
44 .Select(t => t.Exception!.InnerException!);
45
46 throw new AggregateException("Some orders failed", failures);
47 }
48}
Task.WhenAll only throws the first exception. Inspect individual Task.Exception properties to get all failures.
Common Pitfalls
Fire-and-forget in loops: Calling processAsync(item) without await in a foreach loop starts all tasks but does not wait for any. Exceptions are silently lost and the method returns before processing finishes.
Capturing loop variable by reference: In for loops, the index i is shared across iterations. Closures that capture i see the final value unless you copy to a local variable.
Task.WhenAll swallowing exceptions: await Task.WhenAll(tasks) throws only the first exception. If multiple tasks fail, you must inspect each task's Exception property to find all errors.
Unbounded concurrency: Task.WhenAll with thousands of tasks can exhaust thread pool, connections, or memory. Use SemaphoreSlim to cap concurrency when processing large collections.
Mixing sync and async delegates: If the delegate is Action<T> instead of Func<T, Task>, async lambdas become async void, which cannot be awaited and crash the process on unhandled exceptions.
Summary
Use foreach with await for sequential async delegate execution
Use Task.WhenAll(items.Select(x => delegateAsync(x))) for concurrent execution
Limit concurrency with SemaphoreSlim to prevent resource exhaustion
Always use Func<T, Task> (not Action<T>) for async delegates to enable proper awaiting
Copy for loop variables to local variables to avoid closure capture bugs
Inspect individual Task.Exception properties after Task.WhenAll to find all failures