Async, Await, and Task in C#

Async, Await, and Task in C#: A Practical .NET Guide

Async code in .NET is not about making everything faster.

It is about not wasting threads while your app is waiting.

When a web request, database call, file read, or API call takes time, the thread should not sit blocked. It should be returned to the runtime so the app can keep serving users, updating the UI, or processing other work.

That is what async, await, and Task give you.

The Short Version

Task represents work that may finish later.

await waits for that work without blocking the current thread.

async lets a method use await and return a Task.

public async Task<Order?> GetOrderAsync(Guid id, CancellationToken cancellationToken)
{
    return await db.Orders
        .FirstOrDefaultAsync(order => order.Id == id, cancellationToken);
}

The method reads like normal synchronous code, but it does not block the thread while the database is doing its work.

Why Use Async?

Use async when your code spends time waiting.

Common examples:

  • Calling an HTTP API
  • Querying a database
  • Reading or writing files
  • Sending messages
  • Waiting for cloud services
  • Loading data in a Blazor component

In a web app, blocking threads reduces scalability. If every request blocks a thread while waiting for I/O, the server runs out of available threads faster.

In a UI app, blocking the UI thread freezes the interface.

In Blazor, async code helps keep components responsive while data loads.

Async does not remove the wait. It lets the app do something useful while waiting.

How Does It Work?

An async method usually returns one of these:

Task        // operation with no result
Task<T>     // operation with a result

Example:

public async Task<string> DownloadProfileAsync(HttpClient httpClient, int userId)
{
    var response = await httpClient.GetStringAsync($"/users/{userId}");
    return response;
}

When execution reaches await, the method pauses.

The thread is released.

When the operation completes, the method continues from where it stopped.

That is the key idea: await pauses the method, not the thread.

When Should You Use Task.WhenAll?

Use Task.WhenAll when independent async operations can run at the same time.

Sequential version:

var user = await GetUserAsync(id);
var orders = await GetOrdersAsync(id);
var invoices = await GetInvoicesAsync(id);

This runs one operation after another.

Concurrent version:

var userTask = GetUserAsync(id);
var ordersTask = GetOrdersAsync(id);
var invoicesTask = GetInvoicesAsync(id);

await Task.WhenAll(userTask, ordersTask, invoicesTask);

var user = await userTask;
var orders = await ordersTask;
var invoices = await invoicesTask;

This starts all three operations first, then waits for all of them to finish.

Use this only when the operations are independent. If one result is needed before starting the next operation, keep the awaits sequential.

When Should You Use Task.Run?

Use Task.Run for CPU-bound work that would otherwise block a responsive thread.

Example:

var result = await Task.Run(() => CalculateLargeReport(data));

This can help in desktop apps, mobile apps, or UI-heavy scenarios.

But do not wrap normal I/O calls in Task.Run:

// Avoid this
var data = await Task.Run(() => httpClient.GetStringAsync(url));

For I/O-bound work, use the real async API directly:

var data = await httpClient.GetStringAsync(url);

Rule of thumb:

  • I/O-bound: use await on the async API
  • CPU-bound: consider Task.Run, then measure

What Should You Avoid?

Avoid blocking on async code:

// Avoid
var result = GetDataAsync().Result;
GetDataAsync().Wait();

This can cause deadlocks, hide the real exception inside AggregateException, and waste threads.

Prefer:

var result = await GetDataAsync();

Also avoid async void, except for event handlers.

// Avoid in services and business logic
public async void SaveAsync()
{
    await repository.SaveChangesAsync();
}

Use Task instead:

public async Task SaveAsync()
{
    await repository.SaveChangesAsync();
}

A Task can be awaited, tested, composed, and observed when it fails.

A Practical Checklist

Use this when writing async code in .NET:

  • Add the Async suffix to async method names
  • Return Task or Task<T> from async methods
  • Pass a CancellationToken when the operation may be cancelled
  • Use await instead of .Result or .Wait()
  • Use Task.WhenAll for independent operations
  • Use Task.Run only for CPU-bound work
  • Avoid async void except for event handlers
  • Keep async flowing through the call stack

Final Takeaway

Async in .NET is not magic and it is not automatic parallelism.

It is a way to write non-blocking code that still reads like normal code.

Use it when your app waits on I/O. Use Task.WhenAll when independent work can run together. Use Task.Run only when CPU work would block a responsive thread.

The simplest rule:

If the API already gives you an Async method, await it.

Do not block it.

Resources