Async and Await Keywords

Vaibhav • September 11, 2025

In the previous article, we explored the Task-Based Asynchronous Pattern (TBAP), which introduced the Task class as a powerful abstraction for asynchronous operations. While Task simplifies concurrency, writing clean and readable asynchronous code still requires a bit of finesse. That’s where the async and await keywords come in. These two keywords transform how we write asynchronous code in C#, making it easier to understand, maintain, and debug.

In this article, we’ll dive deep into how async and await work, how they interact with the compiler, and how they help you write non-blocking code that looks synchronous. We’ll also explore common patterns, best practices, and subtle behaviors that every developer should know.

What Does async Mean?

The async keyword is used to mark a method as asynchronous. It tells the compiler that the method contains one or more await expressions and should be transformed into a state machine that can pause and resume execution.

An async method must return either Task, Task<T>, or void (only for event handlers). Here’s a simple example:


async Task SayHelloAsync()
{
    await Task.Delay(1000);
    Console.WriteLine("Hello after 1 second");
}
    

This method pauses for one second using Task.Delay, then prints a message. The await keyword tells the compiler to pause execution until the delay completes, without blocking the thread.

The async keyword doesn’t make a method run on a separate thread. It enables the use of await inside the method. The actual execution may still happen on the same thread, depending on the context.

What Does await Do?

The await keyword is used to pause the execution of an async method until the awaited task completes. It doesn’t block the thread-instead, it yields control back to the caller and resumes when the task finishes.


async Task<int> GetNumberAsync()
{
    await Task.Delay(500);
    return 42;
}

async Task UseNumberAsync()
{
    int number = await GetNumberAsync();
    Console.WriteLine("Number is: " + number);
}
    

In this example, GetNumberAsync returns a task that completes after half a second. UseNumberAsync awaits that task and receives the result. The code looks synchronous but runs asynchronously.

Compiler Transformation

When you write an async method, the compiler transforms it into a state machine. This machine tracks where the method should resume after each await. It also handles exceptions and return values.

You don’t see this transformation-it’s done behind the scenes. But understanding it helps explain why async methods can’t have ref or out parameters, and why they must return Task or Task<T>.

Note: The compiler-generated state machine is efficient and optimized. You rarely need to worry about performance unless you're writing high-throughput libraries.

Async Return Types

An async method must return one of the following:

  • Task - for methods that don’t return a value
  • Task<T> - for methods that return a value
  • void - only for event handlers

// No return value
async Task SaveAsync() { ... }

// Return value
async Task<string> LoadAsync() { ... }

// Event handler
async void Button_Click(object sender, EventArgs e) { ... }
    

Avoid using async void except for event handlers. It doesn’t allow callers to await the method or catch exceptions.

Awaiting Multiple Tasks

You can await multiple tasks using Task.WhenAll or Task.WhenAny. This is useful when you need to run tasks in parallel and wait for all or any of them to complete.


async Task LoadDataAsync()
{
    Task t1 = Task.Delay(1000);
    Task t2 = Task.Delay(1500);

    await Task.WhenAll(t1, t2);
    Console.WriteLine("Both tasks completed");
}
    

This method starts two tasks and waits for both to finish. The tasks run concurrently, not sequentially.

Exception Handling with await

When you use await, exceptions thrown inside the task are rethrown at the await point. You can catch them using a regular try-catch block:


async Task LoadAsync()
{
    try
    {
        await Task.Run(() => throw new InvalidOperationException("Oops"));
    }
    catch (Exception ex)
    {
        Console.WriteLine("Caught: " + ex.Message);
    }
}
    

This is cleaner than using Task.Wait() or Task.Result, which wrap exceptions in AggregateException.

ConfigureAwait and Context

By default, await captures the current synchronization context and resumes on the same thread. This is important in UI applications, where you must update controls on the UI thread.

In non-UI apps (like console or server code), you can improve performance by skipping context capture:


await Task.Delay(1000).ConfigureAwait(false);
    

This tells the runtime not to resume on the original context. It’s safe in background code but should be avoided in UI code unless you know what you’re doing.

Use ConfigureAwait(false) in library code and server-side logic to avoid unnecessary context switches. Avoid it in UI code unless you’re sure it’s safe.

Async Lambdas

You can use async with lambda expressions. This is useful for event handlers, delegates, and LINQ queries:


button.Click += async (sender, e) =>
{
    await Task.Delay(500);
    Console.WriteLine("Clicked!");
};
    

The lambda is marked async and uses await inside. The compiler handles it just like a regular async method.

Async in Console Applications

Console apps don’t have a synchronization context, so await resumes on a thread pool thread. You can use async Task Main in C# 7.1 and later:


static async Task Main(string[] args)
{
    await Task.Delay(1000);
    Console.WriteLine("Done");
}
    

This allows you to write fully asynchronous console apps without blocking.

Common Mistakes

Async/await is powerful but easy to misuse. Here are some common mistakes:

  • Using .Result or .Wait() in async methods (causes deadlocks)
  • Using async void outside of event handlers (exceptions can’t be caught)
  • Not awaiting tasks (leads to unobserved exceptions)
  • Mixing synchronous and asynchronous code poorly

If you forget to await a task, it runs in the background and any exceptions it throws may be lost. Always await tasks unless you have a specific reason not to.

Summary

The async and await keywords revolutionize asynchronous programming in C#. They let you write non-blocking code that looks synchronous, improving readability and maintainability. You’ve learned how async marks a method for transformation, how await pauses execution, and how to handle exceptions, contexts, and return values.

In the next article, we’ll explore Async Method Design, where we’ll learn how to structure async methods for clarity, performance, and composability. We’ll also cover naming conventions, cancellation support, and how to expose async APIs to consumers.