Skip to content

Async / Await

Async/await is one of the most commonly asked C# topics in interviews. The key is to understand what's actually happening under the hood, not just "I use it to avoid blocking."


The Core Idea

async and await enable asynchronous, non-blocking code that reads like synchronous code. When you await an operation, the current thread is freed to do other work while the operation completes โ€” rather than sitting idle waiting.

// Synchronous โ€” thread blocks while waiting for DB response
public Order GetOrder(int id)
{
    return _db.Orders.Find(id);  // Thread sits here doing nothing
}

// Asynchronous โ€” thread is freed while awaiting DB response
public async Task<Order> GetOrderAsync(int id)
{
    return await _db.Orders.FindAsync(id);  // Thread is released, returns when ready
}

How It Actually Works

  1. When you await an incomplete task, the method is suspended at that point
  2. The calling thread is returned to the thread pool (for ASP.NET) or the calling context
  3. A state machine is generated by the compiler to remember where to resume
  4. When the awaited task completes, a thread (often the same one, sometimes a different one) resumes the method from where it left off
  5. Execution continues after the await as if it had never left

The compiler transforms your async method into a state machine class. await is syntax sugar for hooking into Task's continuation mechanism.


Return Types

async Task DoWorkAsync()           // Void-returning async (returns Task)
async Task<T> GetDataAsync()       // Returns Task<T> where T is the actual result
async ValueTask<T> GetDataAsync()  // Lightweight version โ€” avoids heap alloc if result is synchronous
async void OnButtonClick()         // Fire-and-forget โ€” only use for event handlers!

async void is dangerous outside event handlers โ€” exceptions can't be caught by the caller, and you can't await it or track when it completes.

// โŒ Don't do this โ€” exception is unobservable
async void FireAndForget()
{
    await Task.Delay(1000);
    throw new Exception("Nobody will catch this!");
}

// โœ… If you need fire-and-forget, at least log errors
_ = Task.Run(async () =>
{
    try { await DoWorkAsync(); }
    catch (Exception ex) { _logger.LogError(ex, "Background task failed"); }
});

Common Mistakes

1. async void (outside event handlers)

As described above โ€” don't do it.

2. Not awaiting (fire-and-forget accidentally)

// โŒ Not awaited โ€” exception silently swallowed, operation may not complete before response
public async Task<IActionResult> SaveAsync()
{
    _repository.SaveAsync();  // โ† Missing await!
    return Ok();
}

// โœ… Await it
public async Task<IActionResult> SaveAsync()
{
    await _repository.SaveAsync();
    return Ok();
}

3. .Result or .Wait() causing deadlocks

// โŒ Classic ASP.NET deadlock โ€” don't block on async code in synchronous context
public string GetData()
{
    return GetDataAsync().Result;  // Deadlock in ASP.NET (not .NET Core)
}

// โœ… Make the whole chain async
public async Task<string> GetDataAsync()
{
    return await FetchFromDbAsync();
}

In .NET Core / ASP.NET Core there's no SynchronizationContext by default, so .Result doesn't deadlock the same way โ€” but it still blocks a thread and should be avoided.

4. Unnecessary async/await (overhead without benefit)

// โŒ Unnecessary wrapping โ€” adds state machine overhead for no reason
public async Task<string> GetNameAsync()
{
    return await _repository.GetNameAsync();  // Just wrapping a single await
}

// โœ… Return the Task directly (no async keyword needed)
public Task<string> GetNameAsync()
{
    return _repository.GetNameAsync();
}

Exception: If you have a using block, try/catch, or multiple await calls, you need the async keyword.

5. Not using ConfigureAwait(false) in libraries

// Library code โ€” don't capture the synchronization context
public async Task<Data> GetDataAsync()
{
    var result = await _httpClient.GetStringAsync(url).ConfigureAwait(false);
    return Parse(result);
}

In application code (controllers, pages), you generally don't need ConfigureAwait(false) โ€” there's no SynchronizationContext in ASP.NET Core. But in library code it's best practice to avoid capturing context.


Running Multiple Tasks in Parallel

// โŒ Sequential โ€” runs one at a time
var orders = await GetOrdersAsync();
var customers = await GetCustomersAsync();
var products = await GetProductsAsync();

// โœ… Parallel โ€” all three start simultaneously
var ordersTask    = GetOrdersAsync();
var customersTask = GetCustomersAsync();
var productsTask  = GetProductsAsync();

await Task.WhenAll(ordersTask, customersTask, productsTask);

var orders    = await ordersTask;
var customers = await customersTask;
var products  = await productsTask;

Task.WhenAll โ€” waits for all tasks to complete, throws if any throw. Task.WhenAny โ€” returns as soon as the first task completes (useful for timeouts).


Cancellation

public async Task<List<Order>> GetOrdersAsync(CancellationToken cancellationToken = default)
{
    return await _context.Orders
        .Where(o => o.Status == "Active")
        .ToListAsync(cancellationToken);  // Pass token to EF Core
}

// In the controller:
[HttpGet]
public async Task<IActionResult> GetOrders(CancellationToken cancellationToken)
{
    var orders = await _service.GetOrdersAsync(cancellationToken);
    return Ok(orders);
}

ASP.NET Core passes a CancellationToken that fires when the client disconnects. Always pass it through to avoid doing work nobody will receive.


Interview Q&A

Q: What's the difference between Task.Run and async/await? Task.Run offloads work to a thread pool thread โ€” useful for CPU-bound work. async/await is for I/O-bound work โ€” it doesn't spin up a new thread; it releases the current thread while waiting. Don't wrap async I/O methods in Task.Run โ€” it wastes a thread.

Q: What is a deadlock in async code and how does it happen? It typically happens when you call .Result or .Wait() on an async method in a context with a SynchronizationContext (like old ASP.NET). The continuation needs to resume on the captured context thread, but that thread is blocked waiting for the task โ€” deadlock. Fixed by using async all the way down.

Q: What's ValueTask and when would you use it? ValueTask<T> is a struct (vs Task<T> which is a class). It avoids a heap allocation when the result is already available synchronously (e.g., a cache hit). Use it in high-performance, frequently-called code paths where the fast path often returns synchronously.

Q: What does ConfigureAwait(false) do? By default, await captures the current SynchronizationContext and resumes on it. ConfigureAwait(false) tells the runtime not to capture the context โ€” the continuation can run on any thread pool thread. In library code, this avoids deadlocks and improves performance.