
From Zero to Hero: Asynchronous Programming in C#
Learn how to master asynchronous programming with async await in C#
Async code lets your app handle more work without blocking threads. In C#, async
/await
is the way to write non-blocking I/O that still reads nicely. This post covers basics, the core APIs you'll use every day, and the mistakes to avoid, plus a few patterns that play nicely with ASP.NET Core.
Task
is a promise of a result. For I/O, the OS wakes your task when data arrives, and no thread is parked waiting.async
/await
is composition, not magic. await
registers a continuation and returns control to the caller until the awaited work completes. Behind the scenes, a state machine is being generated to handle all of this for you.Task.Run
only for short CPU work; you don't want to block a request thread.CancellationToken
all the way downvar builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/weather/{city}", async Task<IResult> (string city, CancellationToken ct) =>
{
using var http = new HttpClient();
http.Timeout = TimeSpan.FromSeconds(5);
var json = await http.GetStringAsync($"https://example.com/weather/{city}", ct);
return Results.Text(json, "application/json");
});
app.Run();
CancellationToken
that fires when the client disconnects or a timeout happens.These patterns are trouble in the server code:
// ❌ May cause thread pool starvation
var result = SomeAsync().Result;
// ❌ Queues extra work and blocks the request thread
SomeAsync().Wait();
Do this instead:
// ✅ All the way async
var result = await SomeAsync();
Even though ASP.NET Core doesn't have a classic UI synchronization context, blocking steals threads that the runtime could use for other requests.
Task.WhenAll
using var http = new HttpClient();
Task<string> u1 = http.GetStringAsync("https://api.test/users/1");
Task<string> u2 = http.GetStringAsync("https://api.test/users/2");
Task<string> u3 = http.GetStringAsync("https://api.test/users/3");
var results = await Task.WhenAll(u1, u2, u3);
WhenAll
throws the first exception; you can inspect Task.Exception
to see the aggregate if you need it.SemaphoreSlim
or Parallel.ForEachAsync
Hitting an API 1,000 times at once is impolite and often pointless. Cap concurrency.
var urls = Enumerable.Range(1, 1000).Select(i => $"https://api.test/data/{i}");
using var http = new HttpClient();
using var gate = new SemaphoreSlim(10);
var tasks = urls.Select(async url =>
{
await gate.WaitAsync();
try
{
return await http.GetStringAsync(url);
}
finally
{
gate.Release();
}
});
var data = await Task.WhenAll(tasks);
Or use the built-in helper:
await Parallel.ForEachAsync(urls, new ParallelOptions { MaxDegreeOfParallelism = 10 }, async (url, ct) =>
{
using var http = new HttpClient();
await http.GetStringAsync(url, ct);
});
Thread.Sleep
: WaitAsync
Add a timeout to any task:
var call = http.GetStringAsync("https://api.test/slow", ct);
var withTimeout = call.WaitAsync(TimeSpan.FromSeconds(2), ct);
var payload = await withTimeout; // throws TaskCanceledException after 2s
Task.WaitAsync
(available on .NET 6+) composes timeouts cleanly, no timers to manage.
Link tokens for per-call timeouts:
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(3));
var json = await http.GetStringAsync(url, cts.Token);
Graceful background worker loop:
public sealed class CleanupWorker(IServiceScopeFactory scopes, ILogger<CleanupWorker> log) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
var timer = new PeriodicTimer(TimeSpan.FromMinutes(5));
while (await timer.WaitForNextTickAsync(ct))
{
using var scope = scopes.CreateScope();
var svc = scope.ServiceProvider.GetRequiredService<ICleanupService>();
await svc.RunAsync(ct);
}
}
}
IAsyncEnumerable<T>
Stream results as they arrive to keep memory low and start sending data sooner.
app.MapGet("/ticks", (CancellationToken ct) => GetTicks(ct));
static async IAsyncEnumerable<string> GetTicks([EnumeratorCancellation] CancellationToken ct)
{
for (var i = 0; i < 5; i++)
{
ct.ThrowIfCancellationRequested();
await Task.Delay(500, ct);
yield return DateTime.UtcNow.ToString("O");
}
}
Consume with await foreach
:
await foreach (var tick in GetTicks(ct))
{
Console.WriteLine(tick);
}
ValueTask
the right wayValueTask
can avoid an allocation when results are often already available. Don't return it by default; use it for hot paths where you've measured a win.
public interface ICache
{
ValueTask<string?> TryGetAsync(string key, CancellationToken ct);
}
public sealed class MemoryFirstCache(IMemoryCache cache, HttpClient http) : ICache
{
public ValueTask<string?> TryGetAsync(string key, CancellationToken ct)
{
if (cache.TryGetValue(key, out string? hit))
return ValueTask.FromResult(hit); // no allocation
return new ValueTask<string?>(FetchAndStoreAsync(key, ct));
}
private async Task<string?> FetchAndStoreAsync(string key, CancellationToken ct)
{
var value = await http.GetStringAsync($"https://api.test/data/{key}", ct);
cache.Set(key, value, TimeSpan.FromMinutes(1));
return value;
}
}
Rules of thumb:
ValueTask
for later.Task
.ConfigureAwait(false)
In application code (ASP.NET Core), you generally don't need ConfigureAwait(false)
. In libraries that may be used anywhere (including UI apps), add it to avoid capturing a synchronization context the caller might have:
public async Task<string> LoadAsync(string path, CancellationToken ct)
{
using var fs = File.OpenRead(path);
using var sr = new StreamReader(fs);
return await sr.ReadToEndAsync(ct).ConfigureAwait(false);
}
Since .NET Core came out, we no longer have a synchronization context, so ConfigureAwait(false)
became a lot less relevant.
CancellationToken
. Accept it in endpoints and hand it down..Result
/.Wait()
. Keep the chain async.Task.Run
. It doesn't make I/O faster; it just burns a thread.SemaphoreSlim
or Parallel.ForEachAsync
.Timeout helper:
public static class TaskExt
{
public static Task<T> WithTimeout<T>(this Task<T> task, TimeSpan timeout, CancellationToken ct = default) =>
task.WaitAsync(timeout, ct);
}
Retry-and-cancel (without polly):
public static async Task<T> RetryAsync<T>(Func<CancellationToken, Task<T>> action, int maxAttempts, TimeSpan delay, CancellationToken ct)
{
for (var attempt = 1; ; attempt++)
{
try
{
return await action(ct);
}
catch when (attempt < maxAttempts)
{
await Task.Delay(delay, ct);
}
}
}
Async isn't about making everything run in parallel; it's about not wasting threads while the OS does I/O. Keep the chain async, pass cancellation, compose with WhenAll
, and throttle when needed. If you want a structured path with many more patterns (channels, pipelines, async LINQ, deadlock traps, fine-grained cancellation), check out From Zero to Hero: Asynchronous Programming in C# on Dometrain.
© 2025 Dometrain. All rights reserved.