Back to Blog
C#

C# Async Await: Execution, Pitfalls, and Best Practices

c# async await: Understand how C# async and await work under the hood, write correct async methods, and avoid deadlocks and performance traps.

async/awaitTask-based Asynchronous PatternC# ConcurrencyCancellationTokenPerformance
Diagram of an async method state machine showing await points and thread yield to the thread pool.

C# async await is a powerful pattern, but it's easy to misuse. A common failure is calling .Result on a Task inside a UI event handler, which can freeze the interface. Understanding how async and await actually execute helps you write code that behaves predictably and scales well.

How async and await Change Method Execution

When you mark a method with async, the compiler rewrites it into a state machine. The method starts synchronously until it hits an await expression. At that point, if the awaited operation has not already completed, the method returns an incomplete Task to the caller, and the rest of the method is scheduled as a continuation. This is not a new thread; it's a way to free the current thread while I/O or other work proceeds.

Consider a simple method:

public async Task<string> ReadFileAsync(string path) { using var stream = File.OpenRead(path); using var reader = new StreamReader(stream); return await reader.ReadToEndAsync(); }

The await expression checks whether the Task returned by ReadToEndAsync is already complete. If it is, execution continues synchronously. If not, the method returns an incomplete Task and the continuation runs when the I/O completes. The caller receives the Task immediately, so it can await it or combine it with other operations.

The state machine also captures the current synchronization context, if one exists. In UI applications, this means the continuation runs on the UI thread. In ASP.NET Core, there is no synchronization context by default, so continuations run on thread pool threads. This distinction matters for deadlocks and for how you access UI elements.

Writing an Async Method That Returns a Value

Methods that return a value use Task<T>. The T is the actual result type. Inside the method, you simply return the value; the compiler wraps it in the Task<T> that the method signature promises.

public async Task<int> GetUserScoreAsync(int userId) { var data = await FetchUserDataAsync(userId); return data.Score; }

You can also return Task for void-like operations. The async void pattern is reserved for event handlers because it allows exceptions to be raised on the synchronization context. For all other methods, use Task or Task<T>. An async void method cannot be awaited, and exceptions it throws are difficult to catch, so avoid it outside of event handlers.

If you have a method that does not need to await anything, you can return a completed task directly without async:

public Task<int> GetDefaultScoreAsync() { return Task.FromResult(0); }

This avoids the overhead of the state machine when there is no actual asynchronous work.

Error Handling in Async Methods

Exceptions in async methods are captured and placed on the returned Task. When you await that task, the exception is rethrown at the await point. This means you can use try/catch around await just like synchronous code.

public async Task ProcessAsync() { try { await RiskyOperationAsync(); } catch (InvalidOperationException ex) { // Handle the specific failure LogError(ex); } }

If you never await the task, the exception is unobserved. In .NET, an unobserved task exception does not crash the process by default, but it can lead to silent failures. Always await or explicitly observe tasks that can fail.

When you use async void, exceptions cannot be caught by the caller. They are raised on the synchronization context, which often crashes the application. This is another reason to avoid async void except in event handlers.

Cancellation and Timeouts

Long-running async operations should support cancellation. The standard pattern is to accept a CancellationToken and pass it to the underlying I/O or other async calls.

public async Task<string> DownloadAsync(string url, CancellationToken ct) { using var client = new HttpClient(); return await client.GetStringAsync(url, ct); }

The CancellationToken is cooperative: the operation checks it and throws OperationCanceledException when cancellation is requested. You can create a token with a timeout using CancellationTokenSource.CancelAfter:

using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); try { await DownloadAsync(url, cts.Token); } catch (OperationCanceledException) { // Handle timeout or cancellation }

If your method does CPU-bound work between awaits, you should check ct.ThrowIfCancellationRequested() periodically to honor cancellation. This keeps the operation responsive to user requests or system shutdown.

Common Pitfalls: Blocking, Deadlocks, and Sync-over-Async

Blocking on an async task with .Result or .Wait() is a common source of deadlocks. In a UI application, the UI thread has a synchronization context. When you block the UI thread waiting for a task, the continuation cannot run on that thread because it is blocked. The result is a deadlock.

// Dangerous: can deadlock in UI or ASP.NET Classic var result = GetDataAsync().Result;

In ASP.NET Core, there is no synchronization context, so this may not deadlock, but it still blocks a thread pool thread and defeats the purpose of async. The correct approach is to await all the way up the call stack. If you cannot make a method async, consider redesigning the flow rather than blocking.

Another issue is sync-over-async: calling an async method and blocking on it from a synchronous method. This can cause thread pool starvation under load because each blocked thread is waiting while holding a thread. Prefer async methods throughout the call chain.

Performance Considerations for Async Code

Async does not make individual operations faster; it improves scalability by freeing threads while waiting for I/O. The state machine has some overhead, but it is small compared to the cost of blocking a thread. For high-throughput services, async can reduce memory usage and thread count.

Avoid creating unnecessary async methods. If a method does not actually await anything, it can return a completed task directly, as shown earlier. Also, be cautious with ValueTask<T> for hot paths where the result is often already available. ValueTask<T> avoids allocating a Task object when the operation completes synchronously, but it has restrictions: you can only await it once, and you should not store it in a field.

public ValueTask<int> GetCachedValueAsync() { if (_cache.TryGetValue(out int value)) return new ValueTask<int>(value); return new ValueTask<int>(LoadValueAsync()); }

Use ValueTask<T> only when performance measurements justify it; otherwise, Task<T> is simpler and more flexible.

When Async Is Not the Right Choice

Async is ideal for I/O-bound operations: file access, network calls, database queries. For CPU-bound work, such as image processing or complex calculations, async does not help because the CPU is the bottleneck. Instead, consider Task.Run to offload work to a thread pool thread, but be aware that this adds scheduling overhead.

public Task<int> ComputeAsync(int[] data) { return Task.Run(() => HeavyComputation(data)); }

However, if you are in an ASP.NET Core request, offloading CPU-bound work to a thread pool thread does not improve scalability because the request still needs a thread to complete. It may even hurt performance. Use async for I/O, and keep CPU-bound work synchronous unless you have a specific reason to parallelize it.

Maintaining Async Code in Production

Async code can hide exceptions and cancellation if not handled carefully. Log exceptions at the point where you catch them, and include the operation name or correlation ID. Use ConfigureAwait(false) in library code to avoid capturing the synchronization context when you don't need it, but be careful: in UI applications, you need the context to update UI elements, so only use it when the continuation does not touch UI.

public async Task<string> ReadAsync(string path) { using var stream = File.OpenRead(path); using var reader = new StreamReader(stream); return await reader.ReadToEndAsync().ConfigureAwait(false); }

This prevents the continuation from being posted back to the original synchronization context, which can reduce overhead and avoid deadlocks in some scenarios. However, if the caller is a UI method and you need to update controls after the await, do not use ConfigureAwait(false) there.

Monitoring async methods in production requires attention to task states and cancellation. Use Task.IsCompleted, IsFaulted, and IsCanceled only when you need to inspect a task without awaiting it. For diagnostics, consider logging when a method starts and finishes, including the elapsed time, but avoid blocking in logging calls.

Async and await are not just syntactic sugar; they change the execution model of your application. By understanding how the state machine works, how to handle errors and cancellation, and where async actually helps, you can write code that is both correct and efficient.

c# async await: Practical Usage and Code Examples | RYUSLOG DEV