Async Await
Intro
async and await are .NET's default model for non-blocking I/O. The goal is responsiveness and scalability: while code waits on network, disk, or database I/O, the thread is released so it can do other work. This is why async code keeps UIs responsive and helps servers handle more concurrent requests without proportionally more threads.
The most important mental model: async is not the same as "run on another thread". In many cases, no thread is actively executing your method while an awaited I/O operation is in flight. The thread is returned to the pool and reclaimed when the I/O completes.
How It Works — The State Machine
The C# compiler transforms every async method into a state machine struct. Each await point becomes a state transition. At compile time, this code:
public async Task<string> FetchAsync(string url)
{
var response = await _http.GetAsync(url);
return await response.Content.ReadAsStringAsync();
}
becomes roughly equivalent to a struct with fields capturing local variables (url, response) and a MoveNext() method that switches on the current state. The runtime calls MoveNext() when the awaited operation completes.
What happens at each await:
- The method runs synchronously until it hits an incomplete awaitable.
- The awaitable's
IsCompletedis checked — if already done (cached result, synchronous completion), execution continues without yielding. - If not done, a continuation is registered on the awaitable and the method returns an incomplete
Taskto its caller. - When the I/O completes, the continuation fires and
MoveNext()resumes from the saved state.
This is why await differs from Task.Result and Task.Wait(): those block the current thread, while await releases it.
ConfigureAwait
By default, await captures the current SynchronizationContext (or TaskScheduler) and resumes on it. In a UI app, this means the continuation runs on the UI thread — useful for updating controls. In ASP.NET Core, there is no SynchronizationContext, so this is a no-op.
ConfigureAwait(false) tells the runtime: "resume on any available thread, don't capture the context."
// Library code — no UI or request context needed
var data = await _repo.GetAsync(id).ConfigureAwait(false);
Rule of thumb:
- Library code: always use
ConfigureAwait(false)to avoid context capture overhead and deadlock risk. - Application code (controllers, view models): omit it — you usually want to resume on the original context.
Example
public async Task<OrderDto?> LoadOrderAsync(
int id,
CancellationToken cancellationToken)
{
using var response = await _httpClient.GetAsync(
$"orders/{id}",
cancellationToken);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<OrderDto>(
cancellationToken: cancellationToken);
}
The method does not hold a thread while waiting on network I/O. The continuation runs only when the response is available.
Pitfalls
Sync-over-async deadlock
Calling .Result or .Wait() on a task inside a method that runs under a SynchronizationContext (UI thread, legacy ASP.NET) causes a deadlock. The blocking call holds the context thread; the continuation needs that same thread to resume. Neither can proceed.
// DEADLOCK in UI or legacy ASP.NET — never do this
var result = GetDataAsync().Result;
Fix: await all the way up, or use ConfigureAwait(false) in the async method so the continuation does not need the original context.
Fire-and-forget swallows exceptions
Calling an async method without awaiting it discards the returned Task. Any exception thrown inside is silently lost.
// Exception is lost — the task is never observed
_ = SendEmailAsync(user);
Fix: either await the call, or explicitly handle the task's exception via .ContinueWith or a background queue.
Async void
async void methods cannot be awaited and their exceptions cannot be caught by callers. They exist only for event handlers.
// Caller cannot catch this exception
public async void OnButtonClick(object sender, EventArgs e) { ... }
Fix: use async Task everywhere except event handlers.
Unnecessary Task.Run wrapping
Wrapping already-async I/O in Task.Run wastes a thread pool thread for no benefit.
// Pointless — GetAsync is already async
var result = await Task.Run(() => _http.GetAsync(url));
Fix: await the async method directly.
Questions
Asynchrony is about not blocking while waiting (especially for I/O). An async method can release the current thread while awaiting, and continue later — potentially on the same thread.
Multithreading is about executing work on multiple threads concurrently (for CPU-bound work). Async code can be single-threaded and still be asynchronous.
The key cost difference: async I/O uses no thread while waiting; multithreading always occupies a thread.
await and Task.Result?
await waits asynchronously: it does not block the current thread, it unwraps exceptions as Exception (not AggregateException), and it respects SynchronizationContext.
Task.Result waits synchronously: it blocks the current thread, wraps exceptions in AggregateException, and can deadlock under a SynchronizationContext.
Cost: .Result in a context-aware environment is a deadlock waiting to happen; await is the safe default.
ConfigureAwait(false)?
In library code that does not need to resume on a specific context. It avoids context-capture overhead and eliminates the deadlock risk when library code is called from a context-aware environment.
Do not use it in application-layer code (controllers, view models) where you need to resume on the original context to update UI or access HttpContext.
Because waiting time is no longer paid by tying up worker threads. Released threads can process other requests while I/O is pending. A server with 100 threads can handle thousands of concurrent I/O-bound requests if each thread is released during the wait.
Task.Run with async code?For CPU-bound work that you intentionally offload to a pool thread (e.g., image processing, heavy computation). Do not use it to wrap already-async I/O APIs — that wastes a thread for no benefit.
References
- Async programming scenarios (Microsoft Learn) — official overview of async/await patterns with examples for I/O and CPU-bound work.
- Await, UI, and deadlocks (Stephen Toub, Microsoft) — deep dive into how
SynchronizationContextcauses deadlocks and howConfigureAwait(false)prevents them. - ConfigureAwait FAQ (Stephen Toub, Microsoft) — exhaustive reference on when and why to use
ConfigureAwait(false). - There is no thread (Stephen Cleary) — explains why async I/O does not require a dedicated thread while waiting.
- Threading in C#: Task Parallelism (Joe Albahari) — comprehensive reference on
Task, continuations, and the async state machine. - Async/await best practices (Stephen Cleary) — canonical list of async pitfalls: async void, sync-over-async, fire-and-forget.