Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

C# & .NET (C# 14, .NET 10 LTS)  Module 15 – Asynchronous programming

async and await from first principles

What await does to a C# method and its thread, async methods that return Task and Task<T>, waits that overlap, and the exceptions awaited tasks carry.

  • Intermediate
  • 25 minutes
  • Examples run with .NET 10.0.401 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 25 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Predict which part of an async method runs before its first await returns, and where the rest resumes
  • Write async methods that return Task and Task<T>, and await their results
  • Start independent waits before awaiting them, and move CPU-bound work off the caller with Task.Run
  • Catch the exception an awaited task carries, and explain why an async void handler ends the process instead
What you’ll learn

What you will learn

  • Predict which part of an async method runs before its first await returns, and where the rest resumes
  • Write async methods that return Task and Task<T>, and await their results
  • Start independent waits before awaiting them, and move CPU-bound work off the caller with Task.Run
  • Catch the exception an awaited task carries, and explain why an async void handler ends the process instead
On this page

A price check asks three vendors for a quote, and their answers take 300, 200 and 250 ms to arrive. Awaited one after another, the three take 750 ms; started together, about 300 ms. Either way the waiting needs no thread: await lets the method step aside while the answer travels, so a server keeps its threads for other requests.

You will see what await does to a method and to the thread that runs it, write async methods that return Task and Task<T>, start waits so that they overlap, give CPU-bound work a thread of its own, and catch the exceptions that awaited tasks carry.

await hands control back to the caller and resumes the method later

order.cs calls an async method that waits 500 ms for a quote, standing in for a network call, and prints where each part of the run happens:

order.cs: the order of events around one await C# · order/order.cs
// What await does to a method. The part before the first await runs at once, on the caller's thread; the caller
// gets an unfinished task back; the rest of the method runs later, when the awaited task completes.
Console.WriteLine($"Main calls LoadQuoteAsync (thread-pool thread: {OnPool()})");

Task<int> loading = LoadQuoteAsync();
Console.WriteLine($"Main has a task back: completed {loading.IsCompleted}, status {loading.Status}");

int quote = await loading;
Console.WriteLine($"Main resumes with {quote} (thread-pool thread: {OnPool()})");

static async Task<int> LoadQuoteAsync()
{
    Console.WriteLine($"  LoadQuoteAsync runs up to its await (thread-pool thread: {OnPool()})");

    // Task.Delay stands in for a network call that answers after 500 ms.
    await Task.Delay(500);

    Console.WriteLine($"  LoadQuoteAsync resumes after the wait (thread-pool thread: {OnPool()})");
    return 412;
}

static bool OnPool() => Thread.CurrentThread.IsThreadPoolThread;

Output

Main calls LoadQuoteAsync (thread-pool thread: False)
  LoadQuoteAsync runs up to its await (thread-pool thread: False)
Main has a task back: completed False, status WaitingForActivation
  LoadQuoteAsync resumes after the wait (thread-pool thread: True)
Main resumes with 412 (thread-pool thread: True)

Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run order.cs

Read the five lines in order. LoadQuoteAsync ran on the caller’s thread up to its await, as an ordinary method would. At the await the delay was not finished, so the method stopped there and gave its caller a task: An object that stands for work in progress, such as a Task or a Task<T>. It ends once, with a result, an exception or a cancel; code awaits it to get the outcome, or reads its Status. that was not complete yet. WaitingForActivation is the status .NET gives an async method’s task while the method is still in progress. Main went on to its own await, and the program waited. When the delay ended, the rest of LoadQuoteAsync ran, its continuation: The code that runs when an awaited task completes: the rest of an async method after the await. Without a synchronization context, as in a console app, it runs on a thread-pool thread., then the rest of Main, both on a thread pool: The set of worker threads that .NET keeps and reuses for short pieces of work: the work given to Task.Run, timer callbacks and the continuations of awaited tasks. thread. Step through the same run:

Interactive · message by message One await in order.cs, step by step Step through the run of order.cs. Main is on the left, the async method in the middle, the 500 ms delay on the right.
One await in order.cs, step by step, step 7 of 7. Messages so far: Main to LoadQuoteAsync: LoadQuoteAsync(); LoadQuoteAsync to Task.Delay(500): start a 500 ms timer; LoadQuoteAsync to Main: a task, not complete; Task.Delay(500) to LoadQuoteAsync: delay complete; LoadQuoteAsync to Main: task complete: 412.Mainresumed on the poolLoadQuoteAsyncreturned 412Task.Delay(500)a timerLoadQuoteAsync()start a 500 ms timera task, not completedelay completetask complete: 412

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. The call. Main calls LoadQuoteAsync on the main thread, which is not a thread-pool thread. The body starts at once, as the body of any method does.
  2. Up to the await. LoadQuoteAsync prints its first line, still on the main thread, and calls Task.Delay(500). The delay starts a timer and returns a task that is not complete.
  3. The await returns. The delay has not finished, so the await keeps the rest of LoadQuoteAsync as a continuation and returns. Main gets a task with status WaitingForActivation.
  4. Main awaits it. Main prints that it has a task back, then awaits that task. For the next 500 ms no code of either method runs, and no thread waits inside LoadQuoteAsync.
  5. The timer fires. After 500 ms the timer completes the delay task on a thread-pool thread, and the continuation of LoadQuoteAsync runs there.
  6. The rest of the method. On a thread-pool thread, LoadQuoteAsync prints its second line and returns 412, which completes its task with that result.
  7. Main resumes. The continuation of Main runs, also on a thread-pool thread, and prints "Main resumes with 412". The program ends.

For those 500 ms no thread was inside LoadQuoteAsync. The compiler turns every async method into a The object the compiler writes for an async method. It keeps the method's local variables and the place where it stopped, so the method can go on from that await when the awaited task completes. that remembers its local variables and where it stopped, and the timer, not a waiting thread, resumed it. In a console app the main thread does wait at the end, because the runtime waits for Main’s task before the process exits; in a web server the thread goes back to serve another request.

Reading await as starting a thread

await starts nothing. The call LoadQuoteAsync() started the work, and await only says what to run when that work ends. Microsoft’s guide to the task model says the keywords create no threads of their own, and the run above shows it: the first half ran on the main thread, the second on the thread pool.

Checkpoint

Check: What await does to a method and its thread

  1. Question 1 of 2 In order.cs, which line is printed before LoadQuoteAsync returns its task to Main?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: "LoadQuoteAsync runs up to its await", on the main thread

    The part before the first await of an unfinished task runs synchronously, on the caller's thread. At that await the method returns a task in progress, and the rest runs when the task completes.

  2. Question 2 of 2 order.cs is a console app. On which thread did the line after the await in LoadQuoteAsync run?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A thread-pool thread, because no synchronization context asked for the main thread back

    Without a synchronization context, as in console apps and ASP.NET Core, the continuation after an await runs on a thread-pool thread. A UI app would send it back to the UI thread.

An async method returns a task that promises its result

An async method declares what its task will carry: Task<int> for a method that ends with return 412;, and a plain Task for one that returns nothing. The return statement gives the value, the compiler stores it in the task, and await takes it out again. tasks.cs shows both kinds, and what happens to a call that nobody awaits:

tasks.cs: Task<int>, Task and a call that is not awaited C# · tasks/tasks.cs
// Task<int> promises an int, Task promises only that the work ends. await unwraps the int, and waits for the Task.
Task<int> pending = QuoteAsync("north");
Console.WriteLine($"QuoteAsync gave back a task with status {pending.Status}");

int price = await pending;
Console.WriteLine($"await gave the int inside it: {price}; status now {pending.Status}");

await SaveReceiptAsync("order 7");
Console.WriteLine("order 7: Main went on after the receipt was saved");

// The mistake: the call is not awaited (warning CS4014), so Main goes on at once and the program ends
// before the receipt is saved. Nothing reports it.
SaveReceiptAsync("order 8");
Console.WriteLine("order 8: Main went on without waiting, and the program ends here");

static async Task<int> QuoteAsync(string vendor)
{
    await Task.Delay(200);
    return vendor == "north" ? 412 : 398;
}

static async Task SaveReceiptAsync(string order)
{
    await Task.Delay(1000);
    Console.WriteLine($"  receipt saved for {order}");
}

Output

QuoteAsync gave back a task with status WaitingForActivation
await gave the int inside it: 412; status now RanToCompletion
  receipt saved for order 7
order 7: Main went on after the receipt was saved
order 8: Main went on without waiting, and the program ends here

Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run tasks.cs

await pending gave the int 412 and left the task RanToCompletion. SaveReceiptAsync returns a plain Task: awaiting it gives no value, but Main waited until the receipt for order 7 was saved before it printed its next line. The call for order 8 was not awaited, so Main went straight on, the program ended, and that receipt was never saved. Nothing reported it at run time. The compiler does warn about the call:

warnings/Program.cs: two mistakes in one file C# · warnings/Program.cs
SaveReceiptAsync("order 8");
Console.WriteLine($"Main went on; the receipt has {await CountLinesAsync("total 412\npaid")} lines");

static async Task SaveReceiptAsync(string order)
{
    await Task.Delay(1000);
    Console.WriteLine($"receipt saved for {order}");
}

static async Task<int> CountLinesAsync(string text) => text.Split('\n').Length;
Building warnings Python · warnings_session.py
from session import run

# warnings/Program.cs makes the two mistakes of this lesson: a call whose task is not awaited, and an async method
# with no await in it. Both compile; this SDK warns about the first one only.
run("""
cd warnings
dotnet build -v q
""")

Output

$ cd warnings
$ dotnet build -v q
warnings/Program.cs(1,1): warning CS4014: Because this call is not awaited, execution of the current method continues before the call is completed. Consider applying the 'await' operator to the result of the call. [warnings/App.csproj]

Build succeeded.

warnings/Program.cs(1,1): warning CS4014: Because this call is not awaited, execution of the current method continues before the call is completed. Consider applying the 'await' operator to the result of the call. [warnings/App.csproj]
    1 Warning(s)
    0 Error(s)

Time Elapsed …

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 warnings_session.py

CS4014 marks the call whose task nobody awaits. The second mistake in the file, CountLinesAsync, an async method with no await in it, compiled without a word from this SDK, though Microsoft’s list of compiler messages still describes a warning, CS1998, for it; the section after next shows what such a method does. The return types an async method can have:

The method Its return type What await gives
returns a value, return 412; Task<int> the int
returns nothing Task no value; it waits for the end
often has its value at once ValueTask<int> the int, as Task<int> does (a later lesson)
is an event handler void nothing to await (the last section)
hands out items over time IAsyncEnumerable<T> one item per pass of await foreach

By convention the name ends in Async. Main may be async Task Main() since C# 7.1, and top-level statements may use await directly, as every example here does.

Checkpoint

Check: Task, Task<T> and a task nobody awaits

  1. Question 1 of 2 QuoteAsync is declared static async Task<int> QuoteAsync(string vendor). What is the type of the expression await QuoteAsync("north")?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: int

    An async method that returns Task<T> gives T to its awaiter. The return statement inside it returns a T, and the compiler puts that value into the task.

  2. Question 2 of 2 In tasks.cs the call SaveReceiptAsync("order 8") is not awaited, and the program ends on the next line. What happened to the receipt for order 8?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It was never saved: Main went on at once, and the process ended before the 1,000 ms delay finished

    A task that nobody awaits runs unchecked: its caller does not wait for it, and its exceptions and its end go unnoticed. The build warned CS4014 on exactly this call.

Waits overlap when you start them before you await them

waits.cs asks three vendors for a quote, with waits of 300, 200 and 250 ms. First it awaits each call before it makes the next; then it starts all three calls and awaits them after:

waits.cs: three quotes, one by one and started together C# · waits/waits.cs
using System.Diagnostics;

// Three price quotes, each a wait of a few hundred milliseconds (Task.Delay stands in for the network).
// First each call is awaited before the next one starts; then all three start first and are awaited after.
var watch = Stopwatch.StartNew();
int north = await QuoteAsync("north", 300);
int south = await QuoteAsync("south", 200);
int east = await QuoteAsync("east", 250);
Console.WriteLine($"awaited one by one: cheapest {Math.Min(north, Math.Min(south, east))}, about {Rounded(watch)} ms");

watch.Restart();
Task<int> northTask = QuoteAsync("north", 300);
Task<int> southTask = QuoteAsync("south", 200);
Task<int> eastTask = QuoteAsync("east", 250);
int cheapest = Math.Min(await northTask, Math.Min(await southTask, await eastTask));
Console.WriteLine($"started together:   cheapest {cheapest}, about {Rounded(watch)} ms");

static async Task<int> QuoteAsync(string vendor, int milliseconds)
{
    await Task.Delay(milliseconds);
    return vendor switch
    {
        "north" => 412,
        "south" => 398,
        _ => 405,
    };
}

// The elapsed time, to the nearest 50 ms: the exact figure changes a little from run to run.
static long Rounded(Stopwatch watch) => (long)Math.Round(watch.Elapsed.TotalMilliseconds / 50) * 50;

Output

awaited one by one: cheapest 398, about 750 ms
started together:   cheapest 398, about 300 ms

This output changes from run to run: the elapsed times change a little from run to run, with what else the computer is doing

Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run waits.cs

Awaited one by one, the waits added up: 300 + 200 + 250 = 750 ms. Started together, they overlapped, and the whole check took as long as the slowest vendor, about 300 ms. The second version calls QuoteAsync three times before its first await. Each call runs up to its own await, starts its delay and returns a task at once, so all three delays are running before Main waits for any of them:

Vendor Its wait One by one, it answers at Started together, it answers at
north 300 ms 300 ms 300 ms
south 200 ms 500 ms 200 ms
east 250 ms 750 ms 250 ms

Awaiting inside the loop that makes the calls

foreach (var v in vendors) prices.Add(await QuoteAsync(v)); is the one-by-one version: each pass waits before the next call starts. Make every call first and keep the tasks in a list, then await them; the next lesson combines such a list with Task.WhenAll.

None of the three waits used a thread. A delay is a timer, and a network reply is a notice from the operating system, so I/O-bound: Work that spends its time waiting for something outside the processor, such as a disk, a database or a network reply. While it waits, it needs no thread. work needs a thread only for the moments its own code runs. That is why async code helps most with I/O: a thread that would sit blocked for 300 ms can serve other work instead.

Key idea

Start every independent operation first and await them afterwards: each call runs up to its own await and returns, so the waits overlap and the total is the longest wait, not the sum.

Checkpoint

Check: Starting waits before awaiting them

  1. Question 1 of 2 Four vendors answer after 150, 400, 250 and 300 ms. All four calls are started before the first await, and then each task is awaited. About how many milliseconds does the whole check take?

    Type a number.

    A hint

    When every wait runs at the same time, which one decides when the last answer is in?

    Show the answer: Question 1 of 2

    Answer: 400 ms

    Started together, the waits overlap, so the check takes as long as the slowest one, 400 ms. Awaited one by one they would add up to 150 + 400 + 250 + 300 = 1,100 ms.

  2. Question 2 of 2 A loop does foreach (var v in vendors) prices.Add(await QuoteAsync(v)); for three vendors that wait 300, 200 and 250 ms. How long does it take, and why?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: About 750 ms: each pass awaits its call before the next call starts

    To overlap the waits, make all the calls first and keep their tasks, then await them.

CPU-bound work needs a thread, so it goes to Task.Run

A wait needs no thread, but a computation does. Counting the primes below 10 million keeps a processor busy for the whole count, so it is CPU-bound: Work that keeps a processor busy computing, such as hashing a file or resizing images. It needs a thread for its whole run, which Task.Run can take from the thread pool., and marking the method async changes nothing about where it runs. cpu.cs counts twice: once in an async method with no await, once through Task.Run:

cpu.cs: an async method without await, and Task.Run C# · cpu/cpu.cs
// CPU-bound work. An async method with no await in it runs to its end before it returns, so the caller waits for
// the whole count. Task.Run puts the count on a thread-pool thread and returns an unfinished task at once.
Task<(int Count, bool OnPool)> direct = CountPrimesAsync(10_000_000);
Console.WriteLine($"async method without await: task completed on return: {direct.IsCompleted}");

Task<(int Count, bool OnPool)> offloaded = Task.Run(() => CountPrimes(10_000_000));
Console.WriteLine($"Task.Run: task completed on return: {offloaded.IsCompleted}");

var (count, onPool) = await direct;
Console.WriteLine($"without await: {count} primes, counted on a thread-pool thread: {onPool}");
(count, onPool) = await offloaded;
Console.WriteLine($"with Task.Run: {count} primes, counted on a thread-pool thread: {onPool}");

// The mistake this example shows: async on a method that never awaits.
static async Task<(int Count, bool OnPool)> CountPrimesAsync(int limit) => CountPrimes(limit);

// The sieve of Eratosthenes: the primes below limit.
static (int Count, bool OnPool) CountPrimes(int limit)
{
    var composite = new bool[limit];
    int count = 0;
    for (int n = 2; n < limit; n++)
    {
        if (composite[n])
        {
            continue;
        }

        count++;
        for (long m = (long)n * n; m < limit; m += n)
        {
            composite[m] = true;
        }
    }

    return (count, Thread.CurrentThread.IsThreadPoolThread);
}

Output

async method without await: task completed on return: True
Task.Run: task completed on return: False
without await: 664579 primes, counted on a thread-pool thread: False
with Task.Run: 664579 primes, counted on a thread-pool thread: True

Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run cpu.cs

The async method had finished the whole count before it returned: its task was already complete, and the count ran on the main thread, so the caller could do nothing in the meantime. Task.Run queued the same count to a thread-pool thread and returned a task that was not complete yet, which the caller awaited while the count went on elsewhere. Both found 664579 primes. Microsoft’s guide to async scenarios sorts work with two questions: does the code wait for a result, or does it run an expensive computation? The two kinds of work side by side:

I/O-bound CPU-bound
Spends its time waiting for a disk, a database or the network computing
Needs a thread only while its own code runs for the whole computation
In an async method await the I/O method’s task await Task.Run(...)

Task.Run belongs where one particular thread must stay free, such as the UI thread of a desktop app, or where the caller has other work to do while the count runs. A web request is different: ASP.NET Core already runs it on a thread-pool thread, and Microsoft’s performance guidance says not to call Task.Run and await it at once there, since that only adds a hop to another pool thread. Wrapping a database call in Task.Run gains nothing either: the call already returns a task, and the extra thread would only wait for it.

Coming from Java

A chain of CompletableFuture stages, thenApply and thenCompose, becomes plain statements after await. Java 21’s virtual threads (JEP 444) make threads cheap enough to block; .NET keeps async and await, and .NET 11’s Runtime Async moves async methods into the runtime itself for cleaner stack traces and lower overhead, with the same async and await in the code.

Checkpoint

Check: CPU-bound work and Task.Run

  1. Question 1 of 2 In cpu.cs, CountPrimesAsync is async but contains no await. What did its task report right after the call returned?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: IsCompleted was True: the whole count had already run, on the main thread

    async makes a method able to await, not able to run elsewhere. CPU-bound work runs on whatever thread calls it, unless Task.Run moves it to the thread pool.

  2. Question 2 of 2 Which of these calls in a desktop app is worth wrapping in await Task.Run(...)?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Resizing 200 photos in memory after the user clicks Export

    Task.Run is for computing, not for waiting: it buys a thread, which only CPU-bound work needs.

A failed task keeps its exception until you await it

faults.cs calls a quote method that throws after its wait, then blocks on two more calls instead of awaiting them:

faults.cs: where the exception of a failed task goes C# · faults/faults.cs
// An exception inside an async method does not fly out of the call. It is stored in the task the call returned,
// and await throws it again, as the same exception.
Task<int> quote = QuoteAsync("west");
Console.WriteLine($"the call returned without an exception; status {quote.Status}");

try
{
    int price = await quote;
    Console.WriteLine($"price {price}");
}
catch (InvalidOperationException e)
{
    Console.WriteLine($"await threw {e.GetType().Name}: {e.Message}");
}

Console.WriteLine($"the task is {quote.Status}, its Exception holds {quote.Exception!.InnerExceptions.Count} exception");

// Even a throw before the first await is stored, not thrown at the call.
Task<int> empty = QuoteAsync("");
Console.WriteLine($"empty vendor name: the call returned too; status {empty.Status}");

// Blocking on the task instead of awaiting it: .Result wraps the exception, GetAwaiter().GetResult() does not.
try
{
    _ = QuoteAsync("west").Result;
}
catch (AggregateException e)
{
    Console.WriteLine($".Result threw {e.GetType().Name} around {e.InnerException!.GetType().Name}");
}

try
{
    _ = QuoteAsync("west").GetAwaiter().GetResult();
}
catch (InvalidOperationException e)
{
    Console.WriteLine($"GetAwaiter().GetResult() threw {e.GetType().Name}");
}

static async Task<int> QuoteAsync(string vendor)
{
    ArgumentException.ThrowIfNullOrEmpty(vendor);
    await Task.Delay(100);
    throw new InvalidOperationException($"{vendor} sent no price");
}

Output

the call returned without an exception; status WaitingForActivation
await threw InvalidOperationException: west sent no price
the task is Faulted, its Exception holds 1 exception
empty vendor name: the call returned too; status Faulted
.Result threw AggregateException around InvalidOperationException
GetAwaiter().GetResult() threw InvalidOperationException

Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run faults.cs

The call itself returned normally, with a task still in progress. The exception came out of await quote, as the same InvalidOperationException that was thrown, so a plain try and catch around the await caught it. In between it was stored in the task, which ended Faulted: a faulted task: A task that ended with an unhandled exception. Its Status is Faulted, its Exception property holds the exception inside an AggregateException, and await throws the exception again., whose Exception property is an AggregateException: An exception that holds a list of other exceptions in its InnerExceptions property. Task.Exception is one, because a task can fail with more than one exception, and .Result and Wait() throw it too. holding 1 exception. Even the throw at the top of the method, for an empty vendor name, before any await, was stored the same way: that call returned a task that was already Faulted. Follow the exception of the first call:

Interactive · stage by stage Where the exception of faults.cs goes Step through the life of one exception, from the throw inside QuoteAsync to the catch block in Main.
Where the exception of faults.cs goes, step 6 of 6. Showing QuoteAsync("west"), throw after 100 ms, the task is Faulted, await quote, catch in Main. In focus: result..Result: AggregateExceptionQuoteAsync("west")returns a task at oncethrow after 100 msInvalidOperationExceptionthe task is Faultedits Exception keeps itawait quotethrows the same exceptioncatch in MainInvalidOperationExceptionAggregateException

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The call returns. Main calls QuoteAsync("west"). The method runs up to its await and returns a task in progress, so the call itself throws nothing.
  2. The throw. After the 100 ms delay the method throws InvalidOperationException. Nothing in QuoteAsync catches it.
  3. Stored in the task. The exception leaves the async method and is stored in its task, which ends Faulted. Its Exception property is an AggregateException with this one exception inside.
  4. Rethrown at the await. await quote finds the task Faulted and throws the stored exception again: the same InvalidOperationException, not the AggregateException around it.
  5. Caught by the caller. The try around the await catches it, and Main prints "await threw InvalidOperationException: west sent no price".
  6. Blocking wraps it. Reading .Result instead of awaiting blocks the thread until the task ends and throws the AggregateException itself, with the InvalidOperationException inside it.

Blocking on the task changes the shape of the error. .Result threw the AggregateException with the real exception inside it, while GetAwaiter().GetResult() threw the InvalidOperationException itself. Both hold the thread until the task ends, so async code uses await; Microsoft’s guide to task exceptions keeps the blocking forms for synchronous entry points that cannot await.

A try around the call instead of the await

try { task = QuoteAsync("west"); } catch (InvalidOperationException) { … } catches nothing: the call returns a task, and the exception waits in it. Put the await inside the try, as faults.cs does.

Checkpoint

Check: Exceptions stored in a faulted task

  1. Question 1 of 2 QuoteAsync("west") awaits a 100 ms delay and then throws InvalidOperationException. Where does the exception come out?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: At await quote, as the same InvalidOperationException

    An async method that returns a task never throws out of the call itself. The task ends Faulted, and the await rethrows the stored exception.

  2. Question 2 of 2 Legacy code blocks on a faulted task with QuoteAsync("west").Result inside a catch (InvalidOperationException) block. What happens?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The catch does not match: .Result throws an AggregateException with the InvalidOperationException inside it

    Blocking calls change the exception's shape: .Result and Wait() throw AggregateException, GetAwaiter().GetResult() throws the original. await avoids both the wrapping and the blocking.

async void hands its exception to no one, and the process ends

Event handlers must return void, so C# allows async void methods for them. Such a method gives its caller nothing to await, which changes where its exception goes. handlers.cs fails the same way in two click handlers:

handlers.cs: an async Task handler and an async void one C# · handlers/handlers.cs
// Two click handlers fail the same way. The async Task one returns a task, so its caller awaits it and catches the
// error. The async void one returns nothing to await: its exception goes to the thread pool and ends the process.
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
    Console.WriteLine($"unhandled: {((Exception)e.ExceptionObject).Message}; the process ends: {e.IsTerminating}");

try
{
    await OnPrintClickedAsync(null, EventArgs.Empty);
}
catch (InvalidOperationException e)
{
    Console.WriteLine($"async Task handler: the caller caught '{e.Message}'");
}

try
{
    OnPrintClicked(null, EventArgs.Empty);
    Console.WriteLine("async void handler: the call returned, and no exception reached this try");
}
catch (InvalidOperationException)
{
    Console.WriteLine("never printed: this catch block cannot see the exception");
}

// The async void handler fails while Main waits here.
await Task.Delay(1000);
Console.WriteLine("never printed: the process has already ended");

static async Task OnPrintClickedAsync(object? sender, EventArgs e)
{
    await Task.Delay(100);
    throw new InvalidOperationException("printer out of paper");
}

static async void OnPrintClicked(object? sender, EventArgs e)
{
    await Task.Delay(100);
    throw new InvalidOperationException("printer out of paper");
}

Output (exit status 134)

async Task handler: the caller caught 'printer out of paper'
async void handler: the call returned, and no exception reached this try
unhandled: printer out of paper; the process ends: True

Printed as an error (standard error)

Unhandled exception. System.InvalidOperationException: printer out of paper
   at Program.<<Main>$>g__OnPrintClicked|0_2(Object sender, EventArgs e) in ./handlers.cs:line 38
   at System.Threading.Tasks.Task.<>c.<ThrowAsync>b__124_1(Object state)
   at System.Threading.ThreadPoolWorkQueue.Dispatch()
   at System.Threading.PortableThreadPool.WorkerThread.WorkerThreadStart()
   at System.Threading.Thread.StartCallback()

Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run handlers.cs

The async Task handler’s exception reached its caller’s catch block. The async void handler’s call returned at its first await, and the try around it caught nothing. When that handler threw, 100 ms later, there was no task to store the exception in. A console app has no An object that decides where the continuations of awaits run. UI frameworks install one that sends them back to the UI thread; console apps and ASP.NET Core have none, so continuations run on the thread pool. to post it to, so .NET threw it on a thread-pool thread, where an unhandled exception ends the process. The UnhandledException event reported it with IsTerminating true, the runtime printed the stack trace, and the run ended with exit status 134, before Main’s last line. Step through both paths:

Interactive · step by step The same exception in an async Task and an async void handler Left, the async Task handler; right, the async void one. Step through where each exception ends up.
The same exception in an async Task and an async void handler, step 8 of 8. Showing async Task handler, throws after 100 ms, stored in the task, the caller's catch, async void handler (the try has ended), throws after 100 ms, rethrown on the pool, the process ends. In focus: the process ends.async Task handlerits caller awaits itthrows after 100 msprinter out of paperstored in the taskthe task is Faultedthe caller's catchcatches itasync void handlerthe try has endedthrows after 100 msno task to store itrethrown on the poolan unhandled exceptionthe process endsexit status 134unhandled

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. Two handlers, one failure. Both handlers wait 100 ms and then throw InvalidOperationException. The only difference is the return type: Task on the left, void on the right.
  2. async Task throws. The async Task handler throws after its delay. Its exception has a home: the task the caller is awaiting.
  3. Stored, then rethrown. The exception is stored in the task, which ends Faulted, and the caller's await throws it again.
  4. Caught. The caller's catch block gets it and prints "async Task handler: the caller caught 'printer out of paper'". The program goes on.
  5. async void returns at once. The call to the async void handler returns as soon as it reaches its await, with nothing to await. The try around the call has already ended when the exception comes.
  6. async void throws. After 100 ms the handler throws. There is no task to store the exception in.
  7. On the thread pool. With no synchronization context to post it to, as in a console app, .NET throws it on a thread-pool thread, where nothing can catch it.
  8. The process ends. An unhandled exception on a thread-pool thread ends the process: UnhandledException fired with IsTerminating True, and the run ended with exit status 134 before Main's last line.

Microsoft’s guidance gives async void to event handlers only: the caller cannot catch their exceptions, they are hard to test, and callers do not expect them to still be running. Every other async method returns a task. An event handler that must be async void catches and handles its own exceptions inside.

Key idea

An exception in an async method waits in its task until someone awaits it; an async void method has no task, so its exception reaches no caller and ends the process.

Checkpoint

Check: Why async void ends the process

  1. Question 1 of 2 Why did the try block around OnPrintClicked(null, EventArgs.Empty) in handlers.cs catch nothing?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The async void method returned at its first await, before it threw, and left no task for the exception

    Without a task, the exception of an async void method goes to the synchronization context, or, as in a console app, is thrown on the thread pool, where it ends the process.

  2. Question 2 of 2 A helper async void SendReportAsync() is called from three places, and its failures crash the service. What is the right fix?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Change it to async Task SendReportAsync() and await it at every call site

    Return Task from every async method that is not an event handler, and keep async void handlers small, catching their own exceptions.

Practise: the cheapest of three quotes

prices.cs checks CheapestQuoteAsync, which asks several vendors for a price. The rules are at the top of the file, and all five checks fail on the starter:

prices.cs: five checks of CheapestQuoteAsync C# · practice/prices.cs
// Practice: write CheapestQuoteAsync, then run this file until all five checks pass.
// The rules: ask every vendor at once (start every call before awaiting any), skip a vendor whose task fails,
// return the cheapest price that arrived, and throw InvalidOperationException when no vendor sent a price.
// As written, CheapestQuoteAsync throws NotImplementedException.
(string Check, Func<Task<string>> Run, string Expected)[] checks =
[
    ("three prices", () => Cheapest(new Vendor(412, 300), new Vendor(398, 200), new Vendor(405, 250)), "398"),
    ("one vendor fails", () => Cheapest(new Vendor(412, 300), new Vendor(0, 100, Fails: true), new Vendor(405, 250)), "405"),
    ("the cheapest vendor fails", () => Cheapest(new Vendor(399, 100, Fails: true), new Vendor(420, 200)), "420"),
    ("every vendor fails", () => Cheapest(new Vendor(0, 100, Fails: true), new Vendor(0, 50, Fails: true)), "throws InvalidOperationException"),
    ("all calls start before any finishes", StartedTogether, "3 started when the first finished"),
];

int passed = 0;
foreach (var (check, run, expected) in checks)
{
    string got = await run();
    bool ok = got == expected;
    if (ok)
    {
        passed++;
    }

    Console.WriteLine(ok ? $"{check}: ok" : $"{check}: FAIL, {got} instead of {expected}");
}

Console.WriteLine($"{passed} of {checks.Length} checks pass");
return passed == checks.Length ? 0 : 1;

static async Task<int> CheapestQuoteAsync(params Func<Task<int>>[] vendors)
{
    throw new NotImplementedException();
}

static async Task<string> Cheapest(params Vendor[] vendors)
{
    try
    {
        return (await CheapestQuoteAsync([.. vendors.Select(v => (Func<Task<int>>)v.QuoteAsync)])).ToString();
    }
    catch (Exception e)
    {
        return $"throws {e.GetType().Name}";
    }
}

// Each vendor counts the calls started so far; the first one to finish notes how many there were.
static async Task<string> StartedTogether()
{
    var log = new StartLog();
    Vendor[] vendors = [new(412, 300, Log: log), new(398, 200, Log: log), new(405, 250, Log: log)];
    await Cheapest(vendors);
    return log.SeenByFirst is int n ? $"{n} started when the first finished" : "no vendor finished";
}

sealed class StartLog
{
    public int Started;
    public int? SeenByFirst;
}

sealed record Vendor(int Price, int Milliseconds, bool Fails = false, StartLog? Log = null)
{
    public async Task<int> QuoteAsync()
    {
        if (Log is not null)
        {
            Log.Started++;
        }

        await Task.Delay(Milliseconds);
        if (Log is not null)
        {
            Log.SeenByFirst ??= Log.Started;
        }

        return Fails ? throw new InvalidOperationException("the vendor sent no price") : Price;
    }
}

Output (exit status 1)

three prices: FAIL, throws NotImplementedException instead of 398
one vendor fails: FAIL, throws NotImplementedException instead of 405
the cheapest vendor fails: FAIL, throws NotImplementedException instead of 420
every vendor fails: FAIL, throws NotImplementedException instead of throws InvalidOperationException
all calls start before any finishes: FAIL, no vendor finished instead of 3 started when the first finished
0 of 5 checks pass

Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run prices.cs

Write CheapestQuoteAsync

Copy prices.cs. Call every vendor first and keep the tasks in an array, then await each task inside its own try, skipping a vendor whose task throws InvalidOperationException. Keep the lowest price, and throw InvalidOperationException when none arrived. Run dotnet run prices.cs until it prints 5 of 5 checks pass.

Last, a self-check on all six sections. It begins where your answers so far are weakest, and it gives the reason behind each choice, right or wrong:

Practice that adapts to you

Practise this lesson

8 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 8 An async method awaits Task.Delay(500). Right after the call returns, what does its task report?

    Choose one answer.

    Show the answer: Question 1 of 8

    Answer: IsCompleted False, status WaitingForActivation

    WaitingForActivation is the status of an async method's task while the method is in progress; it becomes RanToCompletion when the method returns.

  2. Question 2 of 8 A cache returns Task.FromResult(price) for a hit, a task that is already complete. What does await do with it?

    Choose one answer.

    Show the answer: Question 2 of 8

    Answer: It takes the result and goes straight on, without returning to the caller

    await checks whether the task has completed. If it has, the method simply continues with the result; only an unfinished task makes it store a continuation and return.

  3. Question 3 of 8 The build of warnings/Program.cs printed one warning. Which line did it point at, and why?

    Choose one answer.

    Show the answer: Question 3 of 8

    Answer: Line 1, the call SaveReceiptAsync("order 8") whose task nobody awaits: CS4014

    Read CS4014 as a likely bug: either await the task, or, if running on unchecked is really meant, discard it explicitly with _ = and handle its failures inside the method.

  4. Question 4 of 8 A web API method does return await Task.Run(() => db.QueryAsync(sql)); "to make it async". What does the Task.Run add?

    Choose one answer.

    Show the answer: Question 4 of 8

    Answer: A thread-pool thread that only starts the query and waits; the query already returned a task

    Task.Run buys a thread for computing. For I/O, await the I/O method's own task; and in ASP.NET Core, Microsoft's performance guidance says not to call Task.Run and await it at once, because requests already run on the pool.

  5. Question 5 of 8 Three waits of 300, 200 and 250 ms are awaited one by one, each call starting after the previous await. About how many milliseconds does it take?

    Type a number.

    Show the answer: Question 5 of 8

    Answer: 750 ms

    One by one the waits add up: 300 + 200 + 250 = 750 ms, the first line of waits.cs. Started together, they would take about 300 ms.

  6. Question 6 of 8 A checkout must call a tax service and a shipping service, which are independent, then a payment service that needs both answers. Which order is fastest?

    Choose one answer.

    Show the answer: Question 6 of 8

    Answer: Start tax and shipping, await both, then call and await payment

    Overlap what is independent and keep the order where one call needs another's result.

  7. Question 7 of 8 QuoteAsync("") throws ArgumentException on its first line, before any await. What does the call do?

    Choose one answer.

    Show the answer: Question 7 of 8

    Answer: It returns a task that is already Faulted; the exception comes out at the await

    In an async method that returns a task, every exception is stored in the task, wherever it is thrown. Microsoft's guide to the task-based pattern says that only usage errors, such as a bad argument, should be thrown from the call itself; that takes a method without async that checks first and then calls the async part.

  8. Question 8 of 8 What did the run of handlers.cs show about the AppDomain.UnhandledException event?

    Choose one answer.

    Show the answer: Question 8 of 8

    Answer: It reported the exception with IsTerminating true, and the process still ended with exit status 134

    An unhandled exception on a thread-pool thread ends the process. UnhandledException lets you log it first; only returning a task, or catching inside the handler, prevents the crash.

Interview questions

Fresher · What does await do to the thread that is running the method?

It lets the thread go. When await meets a task that has not completed, it stores the rest of the method as a continuation and returns to the caller, often with a task of its own; no thread stays blocked inside the method while the operation runs. When the awaited task completes, the continuation is scheduled: on the thread pool in a console app or ASP.NET Core, back on the UI thread in a desktop app. The result, or the stored exception, comes out of the await expression. If the task had already completed, await goes straight on without returning at all. await itself starts no work and creates no thread: the call before it started the operation.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Fresher to mid

    A dashboard endpoint calls four independent services, each answering in about 120 ms, and responds in about 500 ms. Each call is awaited on its own line before the next one is made. Why is it slow, how do you fix it, and what changes when one of the four calls fails?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    A background service crashes a few times a day with an unhandled InvalidOperationException from a method OnMessageReceived, declared async void. The team wrapped every call to it in try and catch, and nothing changed. Explain why, and fix it.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • An async method runs on the caller’s thread up to the first await of an unfinished task, then returns a task; its continuation runs when that task completes, on the thread pool in a console app.
  • Task<T> carries the result that return gives and await takes out; Task carries only the end of the work, and a call whose task nobody awaits runs on unchecked, which CS4014 warns about.
  • Independent waits overlap when every call starts before the first await, so the total is the longest wait rather than the sum; I/O-bound waits hold no thread.
  • An async method with no await runs to its end before it returns, so CPU-bound work that must not block the caller goes to Task.Run.
  • A failed task stores its exception, and await rethrows it as it was thrown, while .Result wraps it in an AggregateException.
  • An async void method has no task to hold its exception, so the exception ends the process; keep async void for event handlers that catch their own errors.

Terms in this lesson

AggregateException
An exception that holds a list of other exceptions in its InnerExceptions property. Task.Exception is one, because a task can fail with more than one exception, and .Result and Wait() throw it too.
continuation
The code that runs when an awaited task completes: the rest of an async method after the await. Without a synchronization context, as in a console app, it runs on a thread-pool thread.
CPU-bound
Work that keeps a processor busy computing, such as hashing a file or resizing images. It needs a thread for its whole run, which Task.Run can take from the thread pool.
faulted task
A task that ended with an unhandled exception. Its Status is Faulted, its Exception property holds the exception inside an AggregateException, and await throws the exception again.
I/O-bound
Work that spends its time waiting for something outside the processor, such as a disk, a database or a network reply. While it waits, it needs no thread.
task
An object that stands for work in progress, such as a Task or a Task<T>. It ends once, with a result, an exception or a cancel; code awaits it to get the outcome, or reads its Status.
thread pool
The set of worker threads that .NET keeps and reuses for short pieces of work: the work given to Task.Run, timer callbacks and the continuations of awaited tasks.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.