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:

  1. The method runs synchronously until it hits an incomplete awaitable.
  2. The awaitable's IsCompleted is checked — if already done (cached result, synchronous completion), execution continues without yielding.
  3. If not done, a continuation is registered on the awaitable and the method returns an incomplete Task to its caller.
  4. 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:

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

References


Whats next