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
.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.