AlgoMaster Logo

async and await

High Priority14 min readUpdated June 6, 2026

The async and await keywords are how C# expresses asynchronous code in a way that still reads top-to-bottom like ordinary synchronous code. The compiler does the heavy lifting: it rewrites the method into a state machine that can pause at any await, return control to the caller, and resume later when the awaited operation finishes. This lesson covers what each keyword does, the return types an async method can use, how exceptions flow back to the caller, and the naming convention that the .NET ecosystem follows.

The async Modifier

The async keyword goes in front of a method, lambda, or local function. It does exactly one thing: it tells the C# compiler to allow await expressions inside that method and to rewrite the method into a state machine.

That's the entire job of async. It does not start a thread. It does not run the method on a thread pool. It does not make the method run in parallel with anything. The method begins executing on whichever thread called it, and it runs synchronously until it hits the first await that has to wait for something.

The first two lines print on the same thread. The method body runs synchronously until it hits await Task.Delay(100). At that point the method suspends, returns a Task<decimal> to the caller, and the delay schedules a continuation on a thread pool thread. When the delay finishes, the rest of the method resumes (often on a different thread, as shown above).

There is no parallelism here. There is concurrency: while the delay is in progress, no thread is blocked. The .NET thread pool is free to do other work.

A method marked async must return one of three types: Task, Task<T>, or void. There's also ValueTask and ValueTask<T> for performance-sensitive code (lesson 04 covers them in depth). The compiler enforces this. Marking a method async and returning int produces a compile error.

The await Operator

await is the other half. It can only appear inside a method (or lambda or local function) marked async. The operand is an awaitable, most commonly a Task or Task<T>.

When the runtime hits an await, it asks the awaited task: "are you already done?" If yes, the method just keeps running synchronously, with no suspension at all. If no, three things happen:

  1. The current method is suspended at this point. Its local variables and "where am I in the code" position get saved into the state machine.
  2. The method returns a Task (or Task<T>) to its caller, representing the work that's still pending.
  3. The runtime arranges for the rest of the method (the continuation) to run when the awaited task completes.

The caller can then await that returned task too, propagating the asynchrony up the call stack.

Read PrintProductAsync top to bottom. Two awaits, both look like normal blocking calls in source. At runtime, the method suspends on the first await, waits roughly 50ms, resumes, then suspends again on the second await, waits roughly 30ms, resumes, and prints. The two fetches happen sequentially because that's how the code is written. Lesson 05 covers Task.WhenAll for running them concurrently.

The value await produces depends on the awaited type. Awaiting a Task<T> produces a T. Awaiting a non-generic Task produces nothing (the expression has no value, used only as a statement).

Awaiting without consuming a result is common. The goal isn't a return value; the call needs to complete before moving on. The runtime still suspends and resumes the method correctly; there's no value to assign.

Each await against a task that's not already complete costs an allocation for the state machine box and the continuation delegate. For methods that hit await on the hot path millions of times per second, this adds up. The ValueTask type (lesson 04) exists to remove that allocation when the result is usually available synchronously.

The Compiler-Generated State Machine

When a method is marked async, the compiler doesn't compile it as written. It generates a hidden struct (or class, depending on the configuration) that implements IAsyncStateMachine, copies the local variables into fields of that struct, and translates the method body into a single MoveNext method with a giant switch on a state field.

This code isn't written by hand, but understanding the shape helps when debugging async stack traces or reasoning about captured variables.

The high-level state flow for a method with two awaits:

The state machine's MoveNext method runs whenever the method needs to make progress: once when the method is first called, and again every time a previously-awaited task completes. Each await is a place where MoveNext might return early after registering a continuation; when the awaited task finishes, the runtime calls MoveNext again and the switch jumps to the line right after that await.

The method's local variables (parameters, locals, the this reference for instance methods) become fields on the state machine struct. That's why a local variable's value survives across an await: it isn't on the stack anymore, it's on the heap inside the state machine box.

A second diagram makes the execution timeline easier to see. Caller invokes the async method, the method runs to its first await, suspends, returns a task, caller continues, eventually the I/O finishes, the continuation resumes the method, it runs to completion, and the returned task transitions to the completed state.

The shape is straightforward. The async method hands back a task immediately, the caller is free to do other work (or await the task to suspend its own continuation), and when the underlying operation finishes, control flows back into the method to finish the work.

The actual generated state machine is visible when decompiling a debug build with a tool like ILSpy or sharplab.io. It's verbose, but makes one point clear: async/await is a language feature implemented by the compiler, not by the runtime. There's no special "async" instruction in IL. The runtime support is a few helper types (AsyncTaskMethodBuilder, TaskAwaiter) that the compiler-generated code uses.

Return Types for Async Methods

An async method can return one of these types:

Return TypeWhen to UseNotes
TaskAsynchronous work with no resultThe caller awaits to know when it's done.
Task<T>Asynchronous work that produces a valueAwaiting gives you the T.
voidEvent handlers onlyCannot be awaited. Exceptions go unobserved.
ValueTask / ValueTask<T>Hot paths where the result is usually synchronousAvoids allocating a Task object. Lesson 04 covers details.

The first two are the common cases. Task is the "future" handed to the caller so they know when the work is done. Task<T> is the same thing but carrying a value when it completes.

The pattern matches a synchronous method returning T or void. The async version wraps the result in a task.

async void

async void exists for one reason: event handlers. Event handler signatures in C# return void (think EventHandler or RoutedEventHandler), so awaiting inside one requires async void. The framework calls the handler and discards the return value, so it can't await the method even if it returned a Task.

Outside of event handlers, async void is a trap. Three reasons:

  1. It can't be awaited. The caller has no Task to hold onto, so it can't know when the method finishes or whether it succeeded.
  2. Exceptions go to the wrong place. An exception thrown from an async void method doesn't surface in any task. It gets posted to whatever SynchronizationContext was active when the method started. In a console app or a background thread, that often means the process crashes.
  3. It's hard to test. Test frameworks rely on awaiting the returned task to know the test is done. With async void, the test method returns the moment the first await is hit, and the framework treats it as passing even if the method later throws.

In some hosts the exception above terminates the process; in others it ends up on an unhandled-exception event. Either way, the caller has no clean way to catch it.

The rule: write async Task (or async Task<T>) by default. Use async void only for actual event handlers.

Exception Flow

Exceptions in async methods behave conventionally, with one twist. When an exception is thrown inside an async method, the runtime captures it, marks the returned task as faulted, and stashes the exception inside the task. The exception is rethrown the moment someone awaits the task.

The try/catch is around the await, not around the call to GetProductPriceAsync. That's important. The method call itself returns a faulted task; it doesn't throw. The exception only escapes when await runs on that task.

Even though the method throws on the very first line (before any await), the exception still gets captured into the returned task instead of propagating out of the method call. This is consistent: async methods always return a task synchronously, then surface results (success, failure, or cancellation) on await.

Stack traces are preserved through await. When a faulted task is awaited, the runtime rethrows the original exception with its stack trace intact, plus a frame showing the resume point. In .NET 6 and later, stack traces from async methods are readable; they include the async method's location and the operator that re-threw.

Throwing exceptions in async code isn't free. The exception gets captured, marshaled into the task, and re-raised on await. Don't use exceptions for control flow inside async methods.

Naming Convention: The Async Suffix

By convention, every method that returns Task, Task<T>, ValueTask, or ValueTask<T> is named with an Async suffix. GetCartAsync, SaveOrderAsync, FetchProductPriceAsync. The whole .NET Base Class Library follows this rule, and so does every third-party library that takes async seriously.

This isn't a language requirement; it's a strong convention. At a call site, the asynchronous nature should be immediately visible, because forgetting to await an async method is a common bug in C#. The suffix is a visual flag.

The two Async-suffixed methods read unambiguously at the call site. They need await.

The suffix applies to methods that return a task type, not methods that happen to call awaitable APIs internally. If a method is fully synchronous from the caller's perspective, don't add Async to the name just because the body does some async work internally.

Two cases where the convention is bent. Methods on interfaces like IAsyncDisposable.DisposeAsync use the suffix even though the verb is already long enough. Some older BCL APIs predate the convention, so method pairs like Stream.Read (synchronous) and Stream.ReadAsync (asynchronous) exist. The asynchronous one always carries the suffix.

What about void-returning async methods (event handlers)? They typically don't get the Async suffix because the framework's event signature determines the name. OnButtonClicked stays OnButtonClicked even when it's async void. Event handlers are wired up by the framework, not called by user code, so the suffix wouldn't help.

Putting It Together

A short E-Commerce snippet that uses everything covered so far: async, await, both task return types, exception flow, and the naming convention.

Two checkouts. The first succeeds: GetStockAsync returns 12, the quantity check passes, ReserveStockAsync runs. The second fails: GetStockAsync throws, the exception surfaces at await, and the catch block prints a message. The two operations look like ordinary sequential code, and the Async suffixes make the asynchronous calls obvious at every call site.

Quiz

async & await Quiz

10 quizzes