Mastering Asynchronous Programming with C# async/await - Part 4: Patterns with Async

Part 4: Patterns with Async
So far, we’ve looked at the basics and pitfalls of async
/await
. Now let’s move to patterns that help you handle real-world scenarios like parallelism, cancellation, streaming, and timeouts.
Pattern 1: Running Tasks in Parallel
Sometimes you want to start multiple operations and wait for all of them.
public async Task FetchInParallelAsync()
{
var client = new HttpClient();
var task1 = client.GetStringAsync("https://example.com/page1");
var task2 = client.GetStringAsync("https://example.com/page2");
var results = await Task.WhenAll(task1, task2);
Console.WriteLine($"Page1 length: {results[0].Length}");
Console.WriteLine($"Page2 length: {results[1].Length}");
}
✅ Use Task.WhenAll
when you need all tasks to finish.
✅ Use Task.WhenAny
when you only care about the first result:
var firstFinished = await Task.WhenAny(task1, task2);
Console.WriteLine(await firstFinished);
Pattern 2: Cancelling Tasks
Long-running operations should be cancellable. Enter CancellationToken
.
public async Task DoWorkAsync(CancellationToken token)
{
for (int i = 0; i < 10; i++)
{
token.ThrowIfCancellationRequested();
Console.WriteLine($"Step {i}");
await Task.Delay(500, token);
}
}
Usage:
var cts = new CancellationTokenSource();
var task = DoWorkAsync(cts.Token);
// Cancel after 2 seconds
cts.CancelAfter(TimeSpan.FromSeconds(2));
try
{
await task;
}
catch (OperationCanceledException)
{
Console.WriteLine("Task was cancelled.");
}
Pattern 3: Async Streams
Introduced in C# 8, IAsyncEnumerable<T>
lets you stream data asynchronously.
public async IAsyncEnumerable<int> GetNumbersAsync()
{
for (int i = 1; i <= 5; i++)
{
await Task.Delay(500); // simulate delay
yield return i;
}
}
Usage:
await foreach (var number in GetNumbersAsync())
{
Console.WriteLine(number);
}
This is great for processing large data sets or network streams without loading everything into memory.
Pattern 4: Timeouts with Task.Delay
You can combine Task.Delay
with Task.WhenAny
to implement timeouts:
public async Task<string> FetchWithTimeoutAsync(string url, int timeoutMs)
{
var client = new HttpClient();
var fetchTask = client.GetStringAsync(url);
var timeoutTask = Task.Delay(timeoutMs);
var finished = await Task.WhenAny(fetchTask, timeoutTask);
if (finished == timeoutTask)
throw new TimeoutException("The request timed out");
return await fetchTask; // Safe to await now
}
Bonus: Retry Pattern
Transient failures (like network hiccups) are common. A simple retry looks like this:
public async Task<T> RetryAsync<T>(Func<Task<T>> operation, int maxRetries = 3)
{
for (int i = 0; i < maxRetries; i++)
{
try
{
return await operation();
}
catch when (i < maxRetries - 1)
{
await Task.Delay(500); // backoff
}
}
throw new Exception("Operation failed after retries.");
}
Key Takeaways
- Use
Task.WhenAll
/Task.WhenAny
for parallelism. - Always provide cancellation with
CancellationToken
. - Use
IAsyncEnumerable<T>
for streaming async data. - Combine
Task.Delay
withTask.WhenAny
for timeouts. - Add retries for resilient systems.
👉 In Part 5, we’ll look at real-world use cases:
- Calling web APIs with
HttpClient
- File I/O with async streams
- Database queries with EF Core
- Keeping UIs responsive
Series Navigation
Previous: Part 3 – Pitfalls & Best Practices Series Index: Overview Next: Part 5 – Real-World Use Cases (Releases 2025-10-15)