Articles › C#
Async/Await in C#: The Complete Guide to Asynchronous Programming
Learn how async and await really work in C# — Tasks, the state machine, ConfigureAwait, cancellation, parallelism and the common mistakes that cause deadlocks and slow APIs.
Asynchronous programming is one of the most important skills for a modern .NET developer. Every time your code talks to a database, calls an HTTP API or reads a file, it waits for something outside the CPU. With async and await, that waiting time is no longer wasted: the thread goes back to the pool and serves other requests in the meantime.
In this guide we'll go from the fundamentals to the details that matter in production: what a Task is, what the compiler generates for an async method, how to run work in parallel, how cancellation works, and the mistakes that cause deadlocks and thread-pool starvation.
Why asynchronous code matters
Imagine an ASP.NET Core API that receives 1,000 requests per second, and each request waits 100 ms for SQL Server. With synchronous code, each request blocks a thread for those 100 ms. The thread pool has to grow, memory usage climbs, and eventually requests start queuing.
With asynchronous code, the thread is released while the database works. When the result arrives, any free thread continues the method. The same server can handle far more concurrent requests with far fewer threads.
Tip: Async is about scalability, not raw speed. A single async call is not faster than its synchronous version — but your application can handle many more of them at the same time.
Task and Task<T>
A Task represents an operation that will complete in the future. Task<T> is the same thing, but produces a value of type T.
public async Task<User?> GetUserAsync(int id)
{
// The thread is released while the database query runs
return await _db.Users.FirstOrDefaultAsync(u => u.Id == id);
}
Three rules to remember:
- A method marked
asyncmust returnTask,Task<T>,ValueTask<T>or (only for event handlers)void. awaitcan only be used inside anasyncmethod.- By convention, asynchronous methods end with the
Asyncsuffix.
What the compiler actually does
When you write an async method, the C# compiler rewrites it into a state machine. Every await becomes a possible pause point:
- The method runs synchronously until the first
await. - If the awaited task is already completed, execution simply continues.
- If not, the method registers a continuation and returns an incomplete
Taskto its caller. - When the awaited operation finishes, the continuation resumes the method from where it stopped.
This is why await doesn't block: the method literally returns, and the rest of it is scheduled to run later.
Running operations in parallel with Task.WhenAll
A very common mistake is awaiting independent operations one after another:
// ❌ Sequential: total time = time(A) + time(B) + time(C)
var user = await GetUserAsync(id);
var orders = await GetOrdersAsync(id);
var messages = await GetMessagesAsync(id);
If the operations don't depend on each other, start them all and then await them together:
// ✅ Concurrent: total time ≈ the slowest of the three
var userTask = GetUserAsync(id);
var ordersTask = GetOrdersAsync(id);
var messagesTask = GetMessagesAsync(id);
await Task.WhenAll(userTask, ordersTask, messagesTask);
var user = userTask.Result; // safe: the task is already completed
var orders = ordersTask.Result;
var messages = messagesTask.Result;
Warning: Entity Framework Core's
DbContextis not thread-safe. Don't run multiple queries concurrently on the sameDbContextinstance — use separate contexts (for example viaIDbContextFactory) if you need real parallelism.
Task.WhenAny is the sibling of WhenAll: it completes as soon as one of the tasks finishes, which is useful for timeouts and "first response wins" scenarios.
Cancellation with CancellationToken
Long-running operations should be cancellable. In ASP.NET Core, every request already has a token that is cancelled when the client disconnects:
[HttpGet("{id:int}")]
public async Task<IActionResult> Get(int id, CancellationToken cancellationToken)
{
var report = await _reports.BuildAsync(id, cancellationToken);
return Ok(report);
}
public async Task<Report> BuildAsync(int id, CancellationToken ct)
{
var rows = await _db.Sales.Where(s => s.ReportId == id).ToListAsync(ct);
ct.ThrowIfCancellationRequested();
return Aggregate(rows);
}
Always pass the token down to every async API that accepts one. You can also create your own timeouts:
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var response = await _http.GetAsync(url, cts.Token);
ConfigureAwait(false): when do you need it?
By default, await captures the current synchronization context and resumes on it. In UI frameworks such as WPF, that means "come back to the UI thread", which is exactly what you want when updating controls.
In library code that doesn't touch the UI, you can avoid that overhead:
var json = await _http.GetStringAsync(url).ConfigureAwait(false);
ASP.NET Core has no synchronization context, so in application code there ConfigureAwait(false) makes no functional difference. It is still a good habit in reusable NuGet libraries that might be called from WPF or WinForms.
The deadly sins of async code
1. Blocking with .Result or .Wait()
// ❌ Can deadlock in UI apps and starves the thread pool in ASP.NET Core
var user = GetUserAsync(id).Result;
Blocking on async code ("sync over async") ties up a thread while waiting for work that itself needs a thread to complete. Make the calling method async instead — async all the way.
2. async void
// ❌ Exceptions crash the process and the caller can't await it
public async void SaveAsync() { ... }
// ✅
public async Task SaveAsync() { ... }
async void exists only for event handlers. Anywhere else, return Task.
3. Fire-and-forget without care
Calling an async method without awaiting it means exceptions disappear silently and the work may be killed when the request ends. In ASP.NET Core, use a BackgroundService or a queue for real background work.
4. Wrapping synchronous code in Task.Run on the server
Task.Run moves work to another thread-pool thread — it doesn't make I/O asynchronous. In a web API, await Task.Run(() => CpuWork()) just swaps one thread for another. Use Task.Run for CPU-bound work in UI apps (to keep the UI responsive), not to "make things async" on the server.
ValueTask: an optimization, not a default
ValueTask<T> avoids allocating a Task when the result is often available synchronously — for example, a cache hit:
public ValueTask<Product?> GetProductAsync(int id)
{
if (_cache.TryGetValue(id, out Product? product))
return ValueTask.FromResult(product); // no allocation
return new ValueTask<Product?>(LoadFromDbAsync(id));
}
A ValueTask must be awaited only once. Unless you've measured a hot path, stick with Task.
Async streams with IAsyncEnumerable
When data arrives in pieces — pages from an API, rows from a large query — IAsyncEnumerable<T> lets you process each item as soon as it's ready:
public async IAsyncEnumerable<Order> StreamOrdersAsync(
[EnumeratorCancellation] CancellationToken ct = default)
{
await foreach (var order in _db.Orders.AsAsyncEnumerable().WithCancellation(ct))
yield return order;
}
await foreach (var order in StreamOrdersAsync())
Console.WriteLine(order.Id);
Exception handling
Exceptions thrown inside an async method are stored in the returned task and re-thrown when you await it, so a normal try/catch works:
try
{
await _payment.ChargeAsync(order);
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Payment gateway failed for order {OrderId}", order.Id);
throw;
}
With Task.WhenAll, await throws the first exception. If you need all of them, inspect the Exception property of the task returned by WhenAll.
A quick checklist
| Situation | Do this |
|---|---|
| Database, HTTP, file I/O | await the async API |
| Independent operations | Start them, then await Task.WhenAll(...) |
| Long operations | Accept and pass a CancellationToken |
| Library code | Consider ConfigureAwait(false) |
| Event handlers | async void is allowed — nowhere else |
| Hot path with frequent sync results | Consider ValueTask<T> (after measuring) |
| Never | .Result, .Wait(), GetAwaiter().GetResult() on request paths |
Summary
async and await let your threads do useful work while waiting for I/O. The compiler turns your method into a state machine that pauses at each await and resumes when the result is ready. Use async all the way down, run independent work concurrently with Task.WhenAll, always flow CancellationToken, and avoid blocking calls and async void. Master these few rules and your .NET applications will scale smoothly under real-world load.
FAQ
Does async make my code run faster?
Not a single operation. It makes your application more scalable because threads aren't blocked while waiting for I/O, so the server can serve more requests concurrently.
Should I use ConfigureAwait(false) in ASP.NET Core?
In application code it has no functional effect, since ASP.NET Core has no synchronization context. In shared libraries it's still recommended.
What's the difference between Task.Run and async/await?
Task.Run executes code on a thread-pool thread — useful for CPU-bound work. async/await is a way to wait for operations without blocking a thread, which is ideal for I/O-bound work.