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 valueTask<T>- for methods that return a valuevoid- 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
.Resultor.Wait()in async methods (causes deadlocks) - Using
async voidoutside 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.