Day 18: Async and Await - Teaching Your Code to Stop Standing Around
arrow_back All Posts
April 06, 2026 10 min read .NET/C#

Day 18: Async and Await - Teaching Your Code to Stop Standing Around

Yesterday you learned about events — wiring up your objects so they can shout into the void and whoever's listening can react. Today we're solving a different kind of problem: your code is rude. Specifically, it hogs the entire thread while waiting for slow things to finish, and everyone else has to stand around doing nothing. Time to teach your program some manners with async and await.

1. The Problem — Your Code Is a Terrible Waiter

Picture a restaurant with one waiter. A customer at Table 1 orders a steak. The waiter walks to the kitchen, hands in the order, and then… stands there. Arms crossed. Staring at the chef. For twelve minutes. Meanwhile, Table 2 is waving desperately for water, Table 3 wants to order, and Table 4 has been waiting for the bill since last Tuesday.

That's synchronous code. Every operation runs one at a time, in order, and nothing else happens until the current thing finishes.

string page1 = DownloadWebPage("https://example.com");   // 3 seconds
string page2 = DownloadWebPage("https://example.org");   // 3 seconds
string page3 = DownloadWebPage("https://example.net");   // 3 seconds
// Total: 9 seconds of your life you'll never get back

Your program sits there frozen for 9 seconds. If this is a desktop app, the UI locks up. If it's a web server, no other requests get handled. The user sees that spinning cursor and starts questioning their life choices.

A good waiter takes Table 1's order, drops it off in the kitchen, and immediately walks to Table 2. The kitchen does its thing in the background, and the waiter picks up the food when it's ready. That's asynchronous programming — and C# makes it shockingly easy.

2. Your First Async Method

Here's the same scenario, but async:

using System.Net.Http;

HttpClient client = new();

string page = await client.GetStringAsync("https://example.com");
Console.WriteLine($"Downloaded {page.Length} characters");

Two new keywords showed up. async marks a method as asynchronous — it tells the compiler "this method will use await inside." await says "start this operation, and while it's running, go do something else. Come back when the result is ready."

If you're running this in a top-level program (Program.cs without a Main method), it just works. But if you're inside a method, you need to decorate it:

static async Task DownloadPageAsync()
{
    HttpClient client = new();
    string page = await client.GetStringAsync("https://example.com");
    Console.WriteLine($"Downloaded {page.Length} characters");
}

Notice the return type changed to Task. That's not an accident — we'll get to why in a second.

3. Task and Task<T> — IOUs From the Runtime

When you call an async method, it doesn't return the result directly. It returns a Task — think of it as a receipt from the kitchen. The receipt says "your food is being prepared." You can hold onto it, check on it later, or await it to get the actual food when it's done.

There are two flavors:

  • Task — the method does work but returns nothing (like void, but async-friendly).
  • Task<T> — the method returns a value of type T when it completes.
// Returns nothing when done
async Task SaveToFileAsync(string text)
{
    await File.WriteAllTextAsync("output.txt", text);
}

// Returns a string when done
async Task<string> ReadFromFileAsync()
{
    return await File.ReadAllTextAsync("output.txt");
}

// Using them
await SaveToFileAsync("Hello, async world!");
string content = await ReadFromFileAsync();
Console.WriteLine(content);

The method signature tells you exactly what to expect. Task<string> means "eventually, this will give you a string." It's a promise. A pinky swear from the runtime.

4. How Await Actually Works — The Waiter Analogy, Continued

Here's what happens step by step when you await something:

  1. Your method starts running normally, top to bottom.
  2. It hits await client.GetStringAsync(...).
  3. The HTTP request fires off in the background.
  4. Your method yields control — it says "I'll come back when this is done" and steps aside.
  5. The thread is free to do other work (handle UI events, process other web requests, whatever).
  6. When the download finishes, the runtime picks up your method exactly where it left off and continues executing.

You don't write any of this plumbing. The async/await keywords and the compiler handle all of it. Under the hood, the compiler rewrites your method into a state machine — a chunk of code that can pause and resume. You don't need to understand the state machine to use async. Just know it exists and be grateful.

async Task MakeBreakfastAsync()
{
    Console.WriteLine("Starting breakfast...");

    Task<string> toastTask = ToastBreadAsync();
    Task<string> eggsTask = FryEggsAsync();
    Task<string> coffeeTask = BrewCoffeeAsync();

    // All three started at the same time!
    string toast = await toastTask;
    string eggs = await eggsTask;
    string coffee = await coffeeTask;

    Console.WriteLine($"{toast}, {eggs}, {coffee}");
    Console.WriteLine("Breakfast is ready!");
}

You started three kitchen tasks at once and awaited them as they finished. That's the difference between a 15-minute sequential breakfast and a 5-minute parallel one.

5. Async All the Way — The Async Virus

Here's a rule that trips up every beginner: async spreads up the call chain. Once a method is async, the method calling it usually needs to be async too. And the method calling that one. And so on, all the way to the top.

// This method is async...
async Task<string> GetWeatherAsync(string city)
{
    HttpClient client = new();
    return await client.GetStringAsync($"https://api.weather.example/{city}");
}

// ...so this one needs to be async too...
async Task DisplayWeatherAsync(string city)
{
    string weather = await GetWeatherAsync(city);
    Console.WriteLine($"Weather in {city}: {weather}");
}

// ...and so does the caller of this one
async Task Main()
{
    await DisplayWeatherAsync("Helsinki");
}

Developers call this the "async virus" — and honestly, it's a feature, not a bug. It forces your entire call chain to be non-blocking. Fighting it by mixing sync and async code leads to deadlocks, which are exactly as fun as they sound (your program freezes forever, no error message, no crash, just silent disappointment).

The rule is simple: async all the way down, await all the way up.

6. Task.WhenAll — Parallel Async

Remember the breakfast example? There's a cleaner way to wait for multiple tasks at once:

async Task FetchMultiplePagesAsync()
{
    HttpClient client = new();

    Task<string> task1 = client.GetStringAsync("https://example.com");
    Task<string> task2 = client.GetStringAsync("https://example.org");
    Task<string> task3 = client.GetStringAsync("https://example.net");

    string[] results = await Task.WhenAll(task1, task2, task3);

    foreach (string page in results)
    {
        Console.WriteLine($"Got {page.Length} characters");
    }
}

Task.WhenAll takes an array (or params list) of tasks and returns a single task that completes when all of them finish. The result is an array of their individual results, in the same order you passed them in.

This is where async really shines. Those three downloads happen simultaneously. If each takes 3 seconds, the total time is about 3 seconds — not 9. Your terrible waiter just became three waiters.

There's also Task.WhenAny, which completes as soon as any one of the tasks finishes. Useful for timeouts or "first one wins" scenarios.

7. Common Mistakes — The Hall of Shame

Async code is easy to write. It's also easy to write wrong. Here are the classics:

Mistake 1: async void — The Fire-and-Forget Footgun

// ❌ DON'T DO THIS
async void LoadDataBadly()
{
    string data = await File.ReadAllTextAsync("data.txt");
    Console.WriteLine(data);
}

// ✅ DO THIS INSTEAD
async Task LoadDataProperly()
{
    string data = await File.ReadAllTextAsync("data.txt");
    Console.WriteLine(data);
}

async void methods can't be awaited, can't be caught with try/catch from the caller, and if they throw an exception, your entire application crashes. The only legitimate use for async void is event handlers in UI frameworks (like WPF or WinForms) because the event signature demands void.

Everywhere else? Use async Task.

Mistake 2: Forgetting to Await

// ❌ This starts the task but never waits for it
Task.Delay(1000);
Console.WriteLine("This prints immediately, not after 1 second");

// ✅ This actually waits
await Task.Delay(1000);
Console.WriteLine("This prints after 1 second");

If you forget await, the task runs in the background and your code charges ahead without the result. The compiler warns you about this — pay attention to warning CS4014.

Mistake 3: Blocking on Async Code (Deadlocks)

// ❌ NEVER DO THIS in UI or ASP.NET code
string result = GetWeatherAsync("Helsinki").Result; // Deadlock city 💀

// ❌ Also bad
GetWeatherAsync("Helsinki").Wait(); // Same problem

// ✅ Just await it
string result = await GetWeatherAsync("Helsinki");

Calling .Result or .Wait() on a task blocks the current thread until the task finishes. But in some environments (WPF, older ASP.NET), the task needs that same thread to complete. Thread waits for task. Task waits for thread. Neither can proceed. Your app is now a statue.

8. Exception Handling in Async Code

Good news: try/catch works exactly like you'd expect with await:

try
{
    HttpClient client = new();
    string page = await client.GetStringAsync("https://this-does-not-exist.fake");
}
catch (HttpRequestException ex)
{
    Console.WriteLine($"Request failed: {ex.Message}");
}

When you await a faulted task, the exception is unwrapped and thrown at the await point. You catch it just like any other exception.

But Task.WhenAll has a quirk. If multiple tasks fail, WhenAll throws only the first exception at the await point. The rest are tucked inside the task's Exception property as an AggregateException:

Task task1 = Task.Run(() => throw new InvalidOperationException("Oops 1"));
Task task2 = Task.Run(() => throw new ArgumentException("Oops 2"));

Task allTasks = Task.WhenAll(task1, task2);

try
{
    await allTasks;
}
catch (Exception ex)
{
    Console.WriteLine($"Caught: {ex.Message}"); // Only the first one

    // To see ALL exceptions:
    if (allTasks.Exception is AggregateException agg)
    {
        foreach (Exception inner in agg.InnerExceptions)
        {
            Console.WriteLine($"  Inner: {inner.Message}");
        }
    }
}

If you're running parallel tasks where any of them might fail, check the AggregateException to avoid silently swallowing errors.

9. Your Homework: Build an Async City Info Fetcher

Build a console app that "fetches" information for multiple cities in parallel. Since we don't want to depend on a real API for this exercise, simulate the network delay with Task.Delay:

using System.Diagnostics;

// Simulates fetching weather data for a city (pretend this calls an API)
async Task<string> GetCityInfoAsync(string city)
{
    int delay = Random.Shared.Next(500, 2000); // Random delay to simulate network
    await Task.Delay(delay);

    string[] conditions = ["Sunny", "Cloudy", "Rainy", "Snowy", "Windy"];
    int temp = Random.Shared.Next(-10, 35);
    string condition = conditions[Random.Shared.Next(conditions.Length)];

    return $"{city}: {temp}°C, {condition} (took {delay}ms)";
}

// The cities to "fetch"
string[] cities = ["Helsinki", "Tokyo", "New York", "London", "Sydney", "Cairo"];

Console.WriteLine("Fetching city info...\n");

// --- Sequential version ---
Stopwatch sw = Stopwatch.StartNew();

foreach (string city in cities)
{
    string info = await GetCityInfoAsync(city);
    Console.WriteLine($"  {info}");
}

sw.Stop();
Console.WriteLine($"\nSequential: {sw.ElapsedMilliseconds}ms\n");

// --- Parallel version ---
sw.Restart();

Task<string>[] tasks = cities.Select(city => GetCityInfoAsync(city)).ToArray();
string[] results = await Task.WhenAll(tasks);

foreach (string info in results)
{
    Console.WriteLine($"  {info}");
}

sw.Stop();
Console.WriteLine($"\nParallel: {sw.ElapsedMilliseconds}ms");
Console.WriteLine($"\nSee the difference? 🚀");

Run it a few times. Watch the sequential version take 5–10 seconds while the parallel version finishes in under 2. That's the whole point of async in one exercise.

Stretch goal: Add error handling — make one of the cities randomly throw an exception and use try/catch around Task.WhenAll to handle it gracefully.

Summary of Day 18

  • Synchronous code blocks the thread while waiting — like a waiter standing idle in the kitchen.
  • async marks a method as asynchronous; await pauses execution until a task completes without blocking the thread.
  • Task is a promise of future work. Task<T> is a promise that eventually produces a value of type T.
  • Async methods should return Task or Task<T> — never async void (except for event handlers).
  • Async is viral — it propagates up the call chain. Embrace it. Don't fight it with .Result or .Wait().
  • Task.WhenAll runs multiple async operations in parallel and waits for all of them.
  • Exception handling works normally with await. For Task.WhenAll, check AggregateException to catch all failures.
  • Never call .Result or .Wait() on tasks in UI or web contexts — that's how you get deadlocks.

Tomorrow: we'll talk about Nullable Types and Null Safety — the billion-dollar mistake and how C# helps you stop making it. You'll learn about null, why Sir Tony Hoare apologized for inventing it, and how modern C# fights back with ?, ??, and nullable reference types.

See you on Day 19!

Share
FM

Farhad Mammadov

.NET Engineer & Cloud Architect · Bayern, Germany. Writing about scalable backend systems, AWS, and SRE.