Back to Blog
C#

Using c# async task: Async/Await in Practice

Learn how to use c# async task effectively: declaring async methods, awaiting tasks, handling errors, avoiding common pitfalls, and understanding performance tradeoffs.

asyncawaitTaskconcurrencyasynchronous programming.NET
Illustration of a C# async task showing a method returning a Task that is awaited, with a timeline of execution and a synchronization context.

When you mark a method with the async keyword and return a Task or Task<T>, you are telling the compiler that this method will perform asynchronous work. The c# async task pattern lets you write non-blocking code that reads like synchronous code, but it comes with rules and tradeoffs that are easy to get wrong. This article explains how async/await actually behaves, where it helps, and where it can hurt if misused.

What an async Task Method Actually Returns

An async method that returns Task represents an ongoing operation that may not have completed when the method returns. The method starts executing synchronously until it hits an await expression. At that point, if the awaited operation is not already complete, the method yields control back to the caller, returning an incomplete Task. When the awaited operation finishes, the method's state machine resumes on the captured synchronization context (or on a thread pool thread if no context exists).

public async Task FetchDataAsync() { var data = await httpClient.GetStringAsync("https://example.com"); Console.WriteLine(data.Length); }

The caller receives a Task immediately. If the method throws an exception before the first await, the exception is captured in the returned Task rather than being thrown synchronously. This is a critical difference from synchronous methods: you cannot catch an exception from an async method with a simple try/catch around the call unless you also await the task.

Declaring an async Method with Task or Task<T>

Use Task for methods that perform work but return no value. Use Task<T> when the method produces a result. The async keyword is required only when the method uses await; a method can return Task without being async by returning an already-created task, but then it cannot use await internally.

public async Task<int> GetCountAsync() { var response = await httpClient.GetAsync("https://example.com/count"); return int.Parse(await response.Content.ReadAsStringAsync()); }

The compiler generates a state machine that tracks progress. Every await in the method becomes a suspension point. The method's local variables are preserved across suspensions, which is why you can use them after an await as if the method had never paused.

Awaiting a Task: What the await Operator Does

When you write await someTask, the compiler checks whether someTask has already completed. If it has, execution continues synchronously—no context switch occurs. If it has not, the method returns an incomplete Task to its caller, and registers a continuation to run when someTask completes. The continuation runs on the captured synchronization context by default. In a UI application, that context is the UI thread, so you can update controls after an await without manually marshaling back. In a console app or ASP.NET Core without a synchronization context, the continuation runs on a thread pool thread.

public async Task ProcessAsync() { var result = await ComputeAsync(); // suspension point Console.WriteLine(result); // runs after ComputeAsync completes }

await can be used on any expression that returns a Task, Task<T>, ValueTask, or a custom awaitable pattern. The await operator does not block the thread; it frees the thread to do other work while the awaited operation runs.

Error Handling in async Task Methods

Exceptions thrown inside an async method are stored in the returned Task. They are observed when the task is awaited. If you never await the task, the exception is effectively unobserved, which can lead to unobserved task exceptions—though the default behavior in modern .NET is to ignore them unless you subscribe to TaskScheduler.UnobservedTaskException. Always await tasks you create, or explicitly handle their exceptions.

try { await FetchDataAsync(); } catch (HttpRequestException ex) { Console.WriteLine($"Request failed: {ex.Message}"); }

Because the exception is captured in the task, you can also inspect it without awaiting by checking task.Exception or using task.GetAwaiter().GetResult(), but that blocks the thread and can cause deadlocks in UI contexts. Prefer await for error propagation.

Performance: Why Blocking Is the Enemy

Async/await does not make your code faster in terms of CPU work. Its benefit is that it avoids blocking threads while I/O operations are pending. When you call .Result or .Wait() on a Task, you block the current thread until the operation completes. In a UI application, that freezes the interface. In a server application, it consumes a thread pool thread that could have handled other requests. The async version releases the thread during the wait, allowing it to serve other work.

// Bad: blocks the calling thread var data = httpClient.GetStringAsync("https://example.com").Result; // Good: releases the thread while waiting var data = await httpClient.GetStringAsync("https://example.com");

Blocking also risks deadlocks when called from a synchronization context. If a UI thread blocks on a task that needs the UI thread to complete its continuation, the operation never finishes. Always use await in async contexts and avoid mixing blocking calls with async code.

Common Pitfalls: async void and Fire-and-Forget

async void methods are intended only for event handlers. They do not return a Task, so exceptions cannot be awaited. An unhandled exception in an async void method crashes the process. For any other scenario, return Task instead.

// Acceptable only in event handlers async void Button_Click(object sender, RoutedEventArgs e) { await DoWorkAsync(); } // Prefer this for all other methods public async Task DoWorkAsync() { await Task.Delay(100); }

Fire-and-forget—calling an async method without awaiting it—is sometimes necessary, but it makes error handling impossible. If you must ignore the task, at least observe its exceptions:

_ = DoWorkAsync(); // discards the task, but exceptions are unobserved

Better: store the task and await it later, or use a background service pattern that handles exceptions.

When to Use Task.Run vs async/await

Task.Run schedules a delegate on the thread pool and returns a Task. It is useful for offloading CPU-bound work from the UI thread. It is not a substitute for asynchronous I/O. If you have a synchronous method that performs a long computation, Task.Run keeps the UI responsive, but it still occupies a thread pool thread. For I/O-bound operations, use native async APIs (like HttpClient methods) rather than wrapping them in Task.Run, because the async APIs do not hold a thread while waiting.

// CPU-bound: offload to thread pool var result = await Task.Run(() => ComputeHeavyResult()); // I/O-bound: use async API directly var data = await httpClient.GetStringAsync("https://example.com");

In a server application, Task.Run can increase thread pool usage without improving scalability. Use it only when you have a specific reason to move work to a background thread, such as avoiding blocking the request thread in a UI scenario.

Cancellation and Timeouts with Task

Cancellation in async methods is cooperative. You pass a CancellationToken to methods that support it, and the method checks for cancellation at safe points. If you need to cancel an operation that does not accept a token, you can use Task.WhenAny to race the operation against a delay, but that does not actually stop the underlying work—it only stops waiting for it.

using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); try { await httpClient.GetStringAsync("https://example.com", cts.Token); } catch (OperationCanceledException) { Console.WriteLine("Request timed out."); }

Always propagate cancellation tokens through your async methods. If you create a new CancellationTokenSource and dispose it, ensure that all tasks using its token are awaited or completed before disposal to avoid ObjectDisposedException.

Maintainability: Keep Async All the Way Down

Async code is most maintainable when you avoid mixing synchronous and asynchronous patterns. If a method is async, callers should await it rather than blocking. If you start with an async API, let the async flow through your call stack. Introducing .Result or .Wait() to bridge sync and async creates deadlock risks and makes the code harder to reason about. If you must expose a synchronous wrapper, consider using GetAwaiter().GetResult() only in contexts without a synchronization context, and document the risk.

// Avoid this pattern in library code public string GetDataSync() { return GetDataAsync().GetAwaiter().GetResult(); }

A better approach is to provide both async and sync implementations when necessary, or to redesign the caller to use async. In modern .NET, ValueTask can reduce allocations when a method often completes synchronously, but it comes with constraints: you can only await it once, and you should not store it. For most scenarios, Task is the safer default.

c# async task: Async/Await in Practice | RYUSLOG DEV