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.
Nick Chapsas is a .NET & C# content creator, educator and a Microsoft MVP for Developer Technologies with years of experience in Software Engineering and Engineering Management.
He has worked for some of the biggest companies in the world, building systems that served millions of users and tens of thousands of requests per second.
Nick creates free content on YouTube and is the host of the Keep Coding Podcast.
More courses by Nick ChapsasΒ© 2025 Dometrain. All rights reserved.