Overview
async and await are C# language features used to write asynchronous code in a readable, sequential style. They are most commonly used with the Task-based Asynchronous Pattern, where asynchronous operations are represented by Task, Task<T>, ValueTask, or ValueTask<T>.
Asynchronous programming is important because many modern applications spend time waiting for external work: HTTP calls, database queries, file I/O, message queues, cloud services, timers, and other operations that do not require a thread to actively execute CPU instructions while waiting. In these situations, async and await allow the application to stay responsive and scalable by returning control to the caller while the operation is in progress.
In C#, async does not automatically create a new thread, and await does not block the current thread. Instead, the compiler transforms an async method into a state machine. When execution reaches an incomplete awaited operation, the method is suspended, control returns to the caller, and the remaining code is scheduled as a continuation when the awaited operation completes.
This topic is important for interviews because many C# developers can use async and await syntactically, but interviews often test whether the candidate understands the semantics: how tasks work, what happens before and after await, how exceptions are propagated, why deadlocks happen, when to use Task.WhenAll, when not to use Task.Run, why async void is dangerous, and how cancellation and context capture work.
Core Concepts
Asynchronous Programming in C#
Asynchronous programming allows code to start an operation and continue later when the operation finishes. It is especially useful for I/O-bound work where the application waits for something outside the current process.
Common examples include:
- Calling an HTTP API
- Querying a database with Entity Framework Core
- Reading or writing files
- Waiting for a message queue operation
- Calling Azure Storage, Service Bus, Cosmos DB, or other cloud services
- Keeping a desktop UI responsive while work is pending
- Handling many concurrent web requests efficiently
Example:
public async Task<string> GetUserJsonAsync(HttpClient httpClient, int userId)
{
string url = $"https://api.example.com/users/{userId}";
string json = await httpClient.GetStringAsync(url);
return json;
}
This method starts an HTTP request and asynchronously waits for the result. While the HTTP request is in progress, the calling thread is not blocked by the await.
async Keyword
The async keyword marks a method, lambda expression, or anonymous method as asynchronous. It enables the use of await inside the method body and tells the compiler to transform the method into an async state machine.
Example:
public async Task<int> CountUsersAsync()
{
await Task.Delay(100);
return 10;
}
Important points:
asyncby itself does not make the method run on another thread.- An async method starts executing synchronously on the calling thread.
- Execution continues synchronously until the method reaches the first incomplete
await. - If there is no
await, the method runs synchronously and the compiler issues a warning. - Async methods should usually return
Task,Task<T>,ValueTask, orValueTask<T>. async voidshould generally be avoided except for event handlers.
Common mistake:
public async Task<int> GetNumberAsync()
{
return 42; // No await. This method runs synchronously.
}
Better:
public Task<int> GetNumberAsync()
{
return Task.FromResult(42);
}
If the method has no actual asynchronous work, do not mark it async unnecessarily.
await Operator
The await operator asynchronously waits for an awaitable operation to complete.
Example:
public async Task ProcessAsync()
{
Console.WriteLine("Before await");
await Task.Delay(1000);
Console.WriteLine("After await");
}
The key behavior is:
- Code before the first incomplete
awaitruns synchronously. - When an incomplete awaited operation is reached, the async method is suspended.
- Control returns to the caller.
- When the awaited operation completes, the remainder of the method continues as a continuation.
- If the awaited operation has already completed, execution can continue synchronously without suspension.
await does not block the thread. This is different from .Result or .Wait(), which block the thread.
Bad:
public string GetData(HttpClient client)
{
return client.GetStringAsync("https://example.com").Result;
}
Better:
public async Task<string> GetDataAsync(HttpClient client)
{
return await client.GetStringAsync("https://example.com");
}
Task-Based Asynchronous Pattern
Most modern .NET asynchronous APIs follow the Task-based Asynchronous Pattern. A Task represents an operation that may complete in the future.
Common async return types:
Example using Task:
public async Task SaveAsync(Order order)
{
await repository.SaveAsync(order);
}
Example using Task<T>:
public async Task<Order?> GetOrderAsync(int id)
{
return await repository.GetByIdAsync(id);
}
How Async Methods Execute
An async method does not immediately become asynchronous just because it has the async modifier.
Example:
public async Task ExampleAsync()
{
Console.WriteLine("A");
await Task.Delay(1000);
Console.WriteLine("B");
}
Execution flow:
ExampleAsync()is called.Console.WriteLine("A")runs immediately on the calling thread.Task.Delay(1000)starts a timer-backed task.awaitsees that the task is not complete.- The method is suspended.
- A
Taskis returned to the caller. - After the delay completes, the continuation runs and prints
B.
This behavior is important because code before the first incomplete await can still block the caller if it performs expensive synchronous work.
Bad:
public async Task<byte[]> DownloadReportAsync(HttpClient client)
{
Thread.Sleep(5000); // Blocks before the first await.
return await client.GetByteArrayAsync("https://example.com/report.pdf");
}
Better:
public async Task<byte[]> DownloadReportAsync(HttpClient client)
{
await Task.Delay(5000); // Non-blocking delay.
return await client.GetByteArrayAsync("https://example.com/report.pdf");
}
Compiler-Generated State Machine
The compiler transforms an async method into a state machine. This state machine stores local variables, tracks the current execution point, and resumes execution after awaited operations complete.
You write:
public async Task<int> CalculateAsync()
{
int value = await GetValueAsync();
return value * 2;
}
Conceptually, the compiler creates logic similar to:
- Start method execution.
- Call
GetValueAsync(). - If the returned task is incomplete, store the current state.
- Return a task to the caller.
- Register a continuation.
- Resume from the saved state when the task completes.
- Complete the returned task with the result or exception.
Interviewers often ask about this to check whether you understand that async/await is not magic threading. It is compiler-generated continuation logic built around awaitable operations.
I/O-Bound Work vs CPU-Bound Work
async and await are most useful for I/O-bound work.
I/O-bound work waits on external systems:
public async Task<Customer?> GetCustomerAsync(int id)
{
return await dbContext.Customers.FindAsync(id);
}
CPU-bound work consumes CPU resources:
public decimal CalculatePremium(Policy policy)
{
// CPU-heavy calculation.
return calculator.Calculate(policy);
}
For CPU-bound work, async does not make the calculation faster. If you need to move CPU-bound work away from a UI thread, Task.Run can be useful.
Example in a desktop UI app:
private async void CalculateButton_Click(object sender, EventArgs e)
{
decimal result = await Task.Run(() => CalculateLargeReport());
resultLabel.Text = result.ToString("N2");
}
In ASP.NET Core request handling, avoid wrapping normal synchronous CPU work in Task.Run just to make it look async. It usually only moves work from one thread pool thread to another and can reduce scalability under load.
Task.Run vs True Async I/O
Task.Run schedules work on the thread pool. It is useful for CPU-bound work that should run on a background thread, especially in client applications.
True async I/O does not require a thread to sit blocked while waiting.
CPU-bound example:
public Task<int> CalculateAsync()
{
return Task.Run(() => ExpensiveCpuCalculation());
}
I/O-bound example:
public async Task<string> ReadFileAsync(string path)
{
return await File.ReadAllTextAsync(path);
}
Do not use Task.Run around already asynchronous I/O:
// Poor practice
public Task<string> GetDataAsync(HttpClient client)
{
return Task.Run(() => client.GetStringAsync("https://example.com"));
}
Better:
public Task<string> GetDataAsync(HttpClient client)
{
return client.GetStringAsync("https://example.com");
}
Async Return Types
Use the return type that matches the operation.
Use Task when the operation has no result:
public async Task SendEmailAsync(EmailMessage message)
{
await emailSender.SendAsync(message);
}
Use Task<T> when the operation returns a result:
public async Task<UserDto> GetUserAsync(int id)
{
User user = await userRepository.GetRequiredAsync(id);
return new UserDto(user.Id, user.Name);
}
Use ValueTask<T> carefully in performance-sensitive code where the operation frequently completes synchronously:
public ValueTask<User?> TryGetCachedUserAsync(int id)
{
if (cache.TryGetValue(id, out User? user))
{
return ValueTask.FromResult(user);
}
return new ValueTask<User?>(database.GetUserAsync(id));
}
Do not use ValueTask<T> everywhere by default. It makes APIs more complex and has usage rules that developers must understand. Task<T> is the default choice for most application code.
Use async void only for event handlers:
private async void SaveButton_Click(object sender, EventArgs e)
{
await SaveAsync();
}
Avoid this in application services:
public async void SaveUserAsync(User user)
{
await repository.SaveAsync(user);
}
Better:
public async Task SaveUserAsync(User user)
{
await repository.SaveAsync(user);
}
async void Pitfalls
async void is dangerous because the caller cannot await it, cannot reliably catch its exceptions, and cannot know when it completes.
Bad:
public async void ProcessOrderAsync(Order order)
{
await paymentService.ChargeAsync(order);
await orderRepository.SaveAsync(order);
}
A caller cannot do this:
await ProcessOrderAsync(order); // Not possible because the method returns void.
Better:
public async Task ProcessOrderAsync(Order order)
{
await paymentService.ChargeAsync(order);
await orderRepository.SaveAsync(order);
}
Acceptable use:
private async void Button_Click(object sender, EventArgs e)
{
try
{
await ProcessOrderAsync(currentOrder);
}
catch (Exception ex)
{
ShowError(ex.Message);
}
}
For event handlers, async void is usually required by the event signature, but the handler should catch and handle exceptions internally.
Exception Semantics
Exceptions in async code are stored in the returned task and rethrown when awaited.
Example:
public async Task<int> DivideAsync(int a, int b)
{
await Task.Delay(100);
return a / b;
}
Caller:
try
{
int result = await DivideAsync(10, 0);
}
catch (DivideByZeroException ex)
{
Console.WriteLine(ex.Message);
}
The exception is observed at the await point.
Important details:
awaitrethrows the exception from a faulted task.try/catchworks naturally withawait.- A task can be completed successfully, faulted, or canceled.
- Blocking with
.Wait()or.Resultcan wrap exceptions differently and can cause deadlocks. Task.WhenAllcan involve multiple exceptions.
Example with Task.WhenAll:
Task first = CallServiceAAsync();
Task second = CallServiceBAsync();
try
{
await Task.WhenAll(first, second);
}
catch
{
if (first.Exception is not null)
{
// Inspect first task errors if needed.
}
if (second.Exception is not null)
{
// Inspect second task errors if needed.
}
throw;
}
When multiple tasks fail, the returned task contains the exception information. If you need every exception, inspect the individual tasks or the Exception property of the task returned by Task.WhenAll.
Cancellation Semantics
Cancellation in .NET async code is cooperative. A caller passes a CancellationToken, and the async operation checks or forwards that token.
Example:
public async Task<string> DownloadAsync(HttpClient client, CancellationToken cancellationToken)
{
return await client.GetStringAsync("https://example.com", cancellationToken);
}
Example with explicit checking:
public async Task ProcessItemsAsync(
IEnumerable<Item> items,
CancellationToken cancellationToken)
{
foreach (Item item in items)
{
cancellationToken.ThrowIfCancellationRequested();
await ProcessItemAsync(item, cancellationToken);
}
}
Important points:
- Cancellation is not forced thread termination.
- The operation must observe the token.
- Canceled tasks are different from faulted tasks.
- Cancellation usually results in
OperationCanceledExceptionorTaskCanceledException. - APIs should accept and pass
CancellationTokenwhere cancellation matters.
Common ASP.NET Core example:
[HttpGet("{id:int}")]
public async Task<ActionResult<UserDto>> GetUser(
int id,
CancellationToken cancellationToken)
{
UserDto? user = await userService.GetUserAsync(id, cancellationToken);
return user is null ? NotFound() : Ok(user);
}
ASP.NET Core can bind the request cancellation token, allowing downstream operations to stop when the client disconnects.
SynchronizationContext and Continuations
When an async method awaits a task, the continuation may try to resume on the captured context.
A context is important in environments such as:
- WPF
- Windows Forms
- older ASP.NET
- some test frameworks
In UI applications, resuming on the UI context is useful because UI controls usually must be accessed from the UI thread.
Example:
private async void LoadButton_Click(object sender, EventArgs e)
{
string text = await httpClient.GetStringAsync("https://example.com");
textBox.Text = text; // Resumes on UI context, so this is safe in typical UI apps.
}
In library code, you often do not need to resume on the original context. ConfigureAwait(false) tells the awaiter that the continuation does not need the captured context.
public async Task<string> GetContentAsync(HttpClient client)
{
return await client
.GetStringAsync("https://example.com")
.ConfigureAwait(false);
}
Practical guidance:
- Application-level code can usually use normal
await. - UI code often needs the captured context to update UI controls.
- General-purpose library code often uses
ConfigureAwait(false)to avoid unnecessary context capture. - ASP.NET Core does not rely on the same classic request SynchronizationContext as older ASP.NET, but blocking async code can still cause thread pool starvation and scalability issues.
Deadlocks and Sync-over-Async
A common async deadlock happens when code blocks on an async operation using .Result or .Wait() while the async operation tries to resume on the blocked context.
Bad:
public string LoadData()
{
return LoadDataAsync().Result;
}
public async Task<string> LoadDataAsync()
{
string data = await httpClient.GetStringAsync("https://example.com");
return data;
}
In a UI or classic ASP.NET context, this can deadlock:
- The caller blocks the context thread with
.Result. - The async operation completes.
- The continuation tries to resume on the captured context.
- The context is blocked waiting for
.Result. - Neither side can continue.
Better:
public async Task<string> LoadDataAsync()
{
return await httpClient.GetStringAsync("https://example.com");
}
Then call it asynchronously all the way up:
string data = await LoadDataAsync();
Best practice: avoid mixing synchronous blocking with async code. This is often called sync-over-async.
Task.WhenAll and Concurrent Async Work
await inside a loop can accidentally run operations sequentially.
Sequential:
foreach (int id in userIds)
{
User user = await userClient.GetUserAsync(id);
users.Add(user);
}
Concurrent:
Task<User>[] tasks = userIds
.Select(id => userClient.GetUserAsync(id))
.ToArray();
User[] users = await Task.WhenAll(tasks);
Use Task.WhenAll when operations are independent and can safely run at the same time.
Be careful with:
- Too many concurrent requests
- Rate limits
- Database connection pool limits
- External API throttling
- Memory pressure
- Ordering requirements
For large workloads, limit concurrency:
public async Task ProcessAllAsync(IEnumerable<int> ids, CancellationToken cancellationToken)
{
using SemaphoreSlim semaphore = new(initialCount: 10);
Task[] tasks = ids.Select(async id =>
{
await semaphore.WaitAsync(cancellationToken);
try
{
await ProcessOneAsync(id, cancellationToken);
}
finally
{
semaphore.Release();
}
}).ToArray();
await Task.WhenAll(tasks);
}
Task.WhenAny and Processing as Tasks Complete
Task.WhenAny completes when any task in a set completes. It is useful for timeouts, racing operations, or processing results as they arrive.
Example:
Task<string> apiCall = client.GetStringAsync("https://example.com");
Task timeout = Task.Delay(TimeSpan.FromSeconds(3));
Task completed = await Task.WhenAny(apiCall, timeout);
if (completed == timeout)
{
throw new TimeoutException();
}
string result = await apiCall;
Important detail: Task.WhenAny returns the completed task. You still need to await that task to get its result or observe its exception.
Fire-and-Forget Work
Fire-and-forget means starting async work without awaiting it.
Bad:
public Task CreateOrderAsync(Order order)
{
_ = emailSender.SendConfirmationAsync(order.Email);
return orderRepository.SaveAsync(order);
}
Problems:
- Exceptions may go unobserved.
- The operation may outlive the request scope.
- Scoped services may be disposed before the work completes.
- The application may shut down before the work finishes.
- There is no retry or monitoring strategy.
Better for ASP.NET Core production code:
public async Task CreateOrderAsync(Order order)
{
await orderRepository.SaveAsync(order);
await backgroundQueue.EnqueueAsync(new SendOrderEmailMessage(order.Id));
}
Use a background queue, hosted service, message broker, or durable workflow for reliable background processing.
Asynchronous Streams with IAsyncEnumerable<T>
IAsyncEnumerable<T> represents a stream of values produced asynchronously. It is useful when values arrive over time or when loading all values into memory at once is not ideal.
Example:
public async IAsyncEnumerable<string> ReadLinesAsync(string path)
{
using StreamReader reader = File.OpenText(path);
while (await reader.ReadLineAsync() is { } line)
{
yield return line;
}
}
Consuming it:
await foreach (string line in ReadLinesAsync("data.txt"))
{
Console.WriteLine(line);
}
Useful scenarios:
- Reading large files
- Streaming database results
- Streaming API responses
- Processing messages over time
When cancellation is needed, design the async stream to accept a CancellationToken.
Async Disposal with IAsyncDisposable
Some resources require asynchronous cleanup, such as flushing buffers or closing network resources.
Example:
await using var stream = new FileStream(
path,
FileMode.Create,
FileAccess.Write,
FileShare.None,
bufferSize: 4096,
useAsync: true);
await stream.WriteAsync(buffer);
await using works with types that implement IAsyncDisposable.
This matters in interviews because candidates should know that async code can affect resource cleanup patterns, not only method calls.
Common Mistakes
Common mistakes include:
- Believing
asyncautomatically creates a new thread - Blocking on async code with
.Resultor.Wait() - Using
async voidoutside event handlers - Forgetting to await a task
- Fire-and-forget without error handling or lifecycle management
- Using
Task.Runaround naturally asynchronous I/O - Awaiting inside a loop when
Task.WhenAllis appropriate - Using unlimited concurrency
- Ignoring cancellation tokens
- Catching exceptions too broadly and hiding failures
- Returning
ValueTask<T>everywhere without a real performance reason - Assuming
ConfigureAwait(false)is always required in application code - Doing expensive synchronous work before the first
await
Example of a forgotten await:
public async Task SaveAsync(Order order)
{
repository.SaveAsync(order); // Missing await.
await auditLog.WriteAsync("Order saved");
}
Better:
public async Task SaveAsync(Order order)
{
await repository.SaveAsync(order);
await auditLog.WriteAsync("Order saved");
}
Best Practices
Use these practices in production C# code:
- Use async APIs for I/O-bound work.
- Use
TaskorTask<T>by default. - Avoid
async voidexcept for event handlers. - Avoid
.Result,.Wait(), andGetAwaiter().GetResult()in normal application code. - Prefer async all the way through the call chain.
- Pass
CancellationTokento I/O and long-running operations. - Use
Task.WhenAllfor independent concurrent operations. - Limit concurrency for large workloads.
- Handle exceptions where meaningful recovery or translation is possible.
- Use
ConfigureAwait(false)mainly in reusable library code when the original context is not needed. - Avoid wrapping true async I/O in
Task.Run. - Use
ValueTask<T>only when measurement or API design justifies it. - Use background services, queues, or message brokers for reliable background work.
- Name asynchronous methods with the
Asyncsuffix.
Example service method:
public async Task<OrderDto> GetOrderAsync(
int orderId,
CancellationToken cancellationToken)
{
Order order = await orderRepository.GetRequiredAsync(orderId, cancellationToken);
Customer customer = await customerRepository.GetRequiredAsync(
order.CustomerId,
cancellationToken);
return new OrderDto(
order.Id,
customer.Name,
order.TotalAmount);
}
If the order and customer calls are independent, run them concurrently:
public async Task<OrderSummaryDto> GetOrderSummaryAsync(
int orderId,
int customerId,
CancellationToken cancellationToken)
{
Task<Order> orderTask = orderRepository.GetRequiredAsync(orderId, cancellationToken);
Task<Customer> customerTask = customerRepository.GetRequiredAsync(customerId, cancellationToken);
await Task.WhenAll(orderTask, customerTask);
Order order = await orderTask;
Customer customer = await customerTask;
return new OrderSummaryDto(order.Id, customer.Name, order.TotalAmount);
}
Async in ASP.NET Core
ASP.NET Core applications commonly use async code for database access, HTTP calls, cloud SDKs, and file operations.
Example:
[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
private readonly AppDbContext dbContext;
public OrdersController(AppDbContext dbContext)
{
this.dbContext = dbContext;
}
[HttpGet("{id:int}")]
public async Task<ActionResult<OrderDto>> GetById(
int id,
CancellationToken cancellationToken)
{
OrderDto? order = await dbContext.Orders
.Where(o => o.Id == id)
.Select(o => new OrderDto(o.Id, o.CustomerName, o.TotalAmount))
.SingleOrDefaultAsync(cancellationToken);
return order is null ? NotFound() : Ok(order);
}
}
Why async matters in ASP.NET Core:
- Frees request-handling threads while waiting for I/O
- Improves scalability under many concurrent requests
- Prevents thread pool starvation caused by blocking waits
- Supports cancellation when clients disconnect
- Integrates naturally with EF Core, HttpClient, and Azure SDKs
Avoid:
OrderDto? order = dbContext.Orders
.Where(o => o.Id == id)
.Select(o => new OrderDto(o.Id, o.CustomerName, o.TotalAmount))
.SingleOrDefaultAsync()
.Result;
Prefer:
OrderDto? order = await dbContext.Orders
.Where(o => o.Id == id)
.Select(o => new OrderDto(o.Id, o.CustomerName, o.TotalAmount))
.SingleOrDefaultAsync(cancellationToken);
Async in Libraries
Reusable libraries should expose async APIs when they perform asynchronous work.
Example:
public interface IFileStorage
{
Task UploadAsync(
string path,
Stream content,
CancellationToken cancellationToken = default);
}
Implementation:
public sealed class BlobFileStorage : IFileStorage
{
private readonly BlobContainerClient container;
public BlobFileStorage(BlobContainerClient container)
{
this.container = container;
}
public async Task UploadAsync(
string path,
Stream content,
CancellationToken cancellationToken = default)
{
BlobClient blob = container.GetBlobClient(path);
await blob.UploadAsync(
content,
overwrite: true,
cancellationToken: cancellationToken).ConfigureAwait(false);
}
}
In library code, ConfigureAwait(false) is often appropriate because the library usually does not need to resume on a caller-specific UI or request context.
Performance Considerations
Async code improves scalability for I/O-bound workloads, but it is not free.
Costs can include:
- State machine allocation or overhead
- Task allocation
- Continuation scheduling
- Capturing execution context
- More complex stack traces
- More complex debugging
Performance guidance:
- Do not make trivial synchronous code async without reason.
- Avoid unnecessary
async/awaitwhen directly returning a task is sufficient and exception semantics are understood. - Use
ValueTask<T>only for hot paths where synchronous completion is common and measurement supports it. - Avoid creating too many tasks for huge collections without throttling.
- Use true async I/O instead of
Task.Runfor I/O.
Example where returning a task directly is fine:
public Task<User?> GetUserAsync(int id, CancellationToken cancellationToken)
{
return userRepository.GetByIdAsync(id, cancellationToken);
}
Example where async/await is useful for exception handling or additional logic:
public async Task<UserDto?> GetUserDtoAsync(int id, CancellationToken cancellationToken)
{
User? user = await userRepository.GetByIdAsync(id, cancellationToken);
return user is null
? null
: new UserDto(user.Id, user.Name);
}