← Back to Articles         
What are async and await, and how do they work internally?

What are async and await, and how do they work internally?

Published on 12 Oct 2026     10 min read C#
Asynchronous Programming (Async/Await)

C# Async and Await – How They Work Internally

1. What are async and await in C#?

async and await are C# keywords used to implement asynchronous programming without blocking the calling thread while waiting for I/O operations.

They allow developers to write asynchronous code in a readable, sequential style.

  • async – Marks a method or lambda as asynchronous and enables the use of await inside it.

  • await – Waits asynchronously for an operation to complete. If the operation is incomplete, the method is suspended without blocking the calling thread.

  • Task – Represents an operation that may complete in the future.

  • Task<T> – Represents an operation that returns a result when completed.

Simple example

public async Task<string> GetCustomerAsync()
{
    await Task.Delay(3000);

    return "Customer Retrieved";
}

Explanation:

  1. async allows the method to use await.

  2. Task.Delay(3000) represents a non-blocking delay of 3 seconds.

  3. await suspends the method if the task has not completed.

  4. After 3 seconds, the method resumes.

  5. The method completes its Task<string> with the returned string.

Important: async does not automatically create a new thread, and await does not always suspend execution. If the awaited task is already complete, execution continues synchronously.

 

2. Complete example program

using System;
using System.Threading.Tasks;

class Program
{
    static async Task Main()
    {
        Console.WriteLine("1. Application Started");

        Task<string> task = GetCustomerAsync();

        Console.WriteLine("3. Main method continues");

        string result = await task;

        Console.WriteLine($"5. Result: {result}");

        Console.WriteLine("6. Application Completed");
    }

    static async Task<string> GetCustomerAsync()
    {
        Console.WriteLine("2. Fetching Customer...");

        await Task.Delay(3000);

        Console.WriteLine("4. Customer data received");

        return "Syed Mohamed";
    }
}

Output

1. Application Started
2. Fetching Customer...
3. Main method continues

(Approximately 3 seconds later)

4. Customer data received
5. Result: Syed Mohamed
6. Application Completed

Program explanation

Step 1: Main starts

Console.WriteLine("1. Application Started");

Prints the first message.

Step 2: Call the asynchronous method

Task<string> task = GetCustomerAsync();

GetCustomerAsync() begins executing immediately.

It prints "Fetching Customer..." and reaches:

await Task.Delay(3000);

Because the delay is incomplete, GetCustomerAsync() suspends and returns an incomplete Task<string> to the caller.

Step 3: Main continues

Console.WriteLine("3. Main method continues");

The main method continues while the delay is pending.

Step 4: Main awaits

string result = await task;

If the task is still incomplete, Main() suspends asynchronously.

Step 5: The delay completes

After approximately 3 seconds, the continuation of GetCustomerAsync() executes and returns "Syed Mohamed".

Step 6: Main resumes

The await task expression produces the string result, and the remaining statements execute.

 

3. How do async and await work internally?

This is the most important part for senior .NET interviews.

When the C# compiler encounters an async method containing await, it typically transforms the method into a compiler-generated state machine.

The state machine tracks:

  • Where execution was suspended.

  • Which local variables must survive across suspension.

  • Which task is being awaited.

  • Where execution should resume.

  • Whether the operation completed successfully or failed.

Internal execution flow

Caller
  |
  v
Call GetCustomerAsync()
  |
  v
Compiler-generated State Machine
  |
  v
Execute method synchronously
until first incomplete await
  |
  v
Start Task.Delay(3000)
  |
  v
Is Task completed?
  |
  +---- YES -------------------+
  |                            |
  |                            v
  |                      Continue execution
  |
  +---- NO
         |
         v
   Save execution state
         |
         v
   Register continuation
         |
         v
   Return incomplete Task
         |
         v
   Calling thread is free
         |
         v
   Task.Delay completes
         |
         v
   Schedule continuation
         |
         v
   Resume state machine
         |
         v
   Execute remaining code
         |
         v
   Complete Task<string>
         |
         v
   Caller receives result

Important clarification

The compiler does not literally pause a thread and later restart the same thread.

Instead, it generates logic that remembers the method's execution state and arranges for the remaining code to run after the awaited operation completes.

 

4. What is an async state machine?

An async state machine is a compiler-generated structure that manages the execution and continuation of asynchronous methods.

Consider:

public async Task<string> GetDataAsync()
{
    Console.WriteLine("Step 1");

    await Task.Delay(2000);

    Console.WriteLine("Step 2");

    return "Completed";
}

Conceptually, the compiler divides this method into execution states.

State -1: Initial execution
    |
    | Print "Step 1"
    |
    | Start Task.Delay(2000)
    |
    v
State 0: Awaiting completion
    |
    | Save required variables
    |
    | Register continuation
    |
    | Suspend method
    |
    v
State -1: Resumed execution
    |
    | Print "Step 2"
    |
    | Return "Completed"
    |
    v
State -2: Completed

These state numbers are illustrative of the usual compiler-generated implementation. They are not application-level states that developers normally manage.

Simplified compiler-generated structure

The following is conceptual code, not the exact output of the C# compiler.

struct GetDataStateMachine : IAsyncStateMachine
{
    public int State;

    public AsyncTaskMethodBuilder<string> Builder;

    public TaskAwaiter Awaiter;

    public void MoveNext()
    {
        // Execute method according to State.
        // If awaited task is incomplete:
        //   Save State
        //   Register continuation
        //
        // When continuation runs:
        //   Resume execution
        //   Complete Builder with result/exception
    }

    public void SetStateMachine(IAsyncStateMachine stateMachine)
    {
    }
}

Important internal components

Component

Responsibility

IAsyncStateMachine

Defines the asynchronous state machine contract

MoveNext()

Executes or resumes the method

AsyncTaskMethodBuilder<T>

Manages completion of the asynchronous method

TaskAwaiter

Supports checking and awaiting task completion

Continuation

Code that executes after the awaited operation completes

State field

Tracks the suspension point

What does MoveNext() do?

MoveNext() is the central method of the generated state machine.

It executes the asynchronous method until one of the following happens:

  1. An incomplete await is encountered.

  2. The method finishes successfully.

  3. An exception occurs.

When the awaited operation completes, the continuation causes MoveNext() to execute again and resume from the saved state.

Key Points

  • The compiler generates the state machine automatically.

  • Developers do not manually create it.

  • Local variables needed after an await are preserved.

  • The state machine enables asynchronous execution without keeping a thread blocked.

  • State machines can introduce allocations and overhead, although modern .NET optimizes many common cases.

 

5. What happens to the thread when await is executed?

Consider:

public async Task<Customer?> GetCustomerAsync(int id)
{
    return await _context.Customers
        .FirstOrDefaultAsync(c => c.Id == id);
}

Thread execution

ASP.NET Core Thread Pool

Thread 1
   |
   |-- Controller receives request
   |
   |-- Calls GetCustomerAsync()
   |
   |-- Starts database operation
   |
   |-- Encounters incomplete await
   |
   +-- Returns to Thread Pool
           |
           | Can process other requests
           |
           v

       SQL Server executes query
           |
           | Database operation completes
           |
           v

Thread Pool schedules continuation
           |
           v

Thread 2 (or another available thread)
   |
   |-- Resumes GetCustomerAsync()
   |
   |-- Processes database result
   |
   |-- Completes Task<Customer?>
   |
   v
Controller resumes and returns response

Important observation

Thread 1 and Thread 2 may be different threads.

In ASP.NET Core, continuations normally execute using available Thread Pool threads because ASP.NET Core does not install a request-specific SynchronizationContext.

await does not guarantee continuation on the same thread.

In UI applications such as WPF and Windows Forms, await normally captures the UI synchronization context and resumes on the UI thread, unless context capture is disabled.

 

6. What happens if the awaited task is already completed?

This is an important interview question.

Consider:

public async Task<int> GetNumberAsync()
{
    await Task.CompletedTask;

    return 100;
}

Task.CompletedTask is already completed.

Therefore:

  1. The state machine checks the awaiter's completion status.

  2. The task is already complete.

  3. Execution continues immediately.

  4. The method returns a successfully completed task.

No asynchronous suspension is required.

await Task.CompletedTask
          |
          v
IsCompleted = true
          |
          v
Continue synchronously
          |
          v
Return 100

This illustrates that await does not always release the thread. It only suspends the method when the awaited operation is incomplete.

 

7. How are exceptions handled internally?

Exceptions in async Task and async Task<T> methods are stored in the returned task.

Consider:

public async Task<int> GetCustomerAsync()
{
    await Task.Delay(1000);

    throw new InvalidOperationException(
        "Customer retrieval failed");
}

Caller:

try
{
    int result = await GetCustomerAsync();
}
catch (InvalidOperationException ex)
{
    Console.WriteLine(ex.Message);
}

Internal flow

GetCustomerAsync()
       |
       v
await Task.Delay(1000)
       |
       v
Continuation resumes
       |
       v
Exception thrown
       |
       v
State machine catches exception
       |
       v
Task marked as Faulted
       |
       v
Caller awaits Task
       |
       v
Exception rethrown at await
       |
       v
Caller catch block executes

Key Points

  • Exceptions from async Task methods are captured by the returned task.

  • await propagates the exception to the caller.

  • Use try-catch around await when the caller must handle the error.

  • Avoid async void except for event handlers because exceptions cannot be observed through a returned task.

 

8. What is the difference between async/await and Task.Run()?

These concepts are frequently confused.

async / await

Task.Run()

Supports asynchronous execution

Schedules work on Thread Pool

Ideal for I/O-bound operations

Useful for CPU-bound work

Does not automatically create a new thread

Uses Thread Pool threads

Can free a thread during I/O waits

Occupies a worker thread while CPU work runs

Example: EF Core ToListAsync()

Example: expensive calculation

Asynchronous I/O

public async Task<string> GetApiDataAsync()
{
    return await _httpClient.GetStringAsync(
        "https://example.com/api/data");
}

This uses asynchronous network I/O.

CPU-bound operation

public async Task<long> CalculateAsync()
{
    return await Task.Run(() =>
    {
        long total = 0;

        for (int i = 0; i < 1000000; i++)
        {
            total += i;
        }

        return total;
    });
}

Task.Run() schedules the calculation on a Thread Pool thread.

In ASP.NET Core, avoid wrapping database or HTTP asynchronous operations in Task.Run(). It generally does not improve scalability.

 

9. Real-world ASP.NET Core Web API example

Consider the following application flow:

HTTP Request
     |
     v
CustomerController
     |
     | await
     v
CustomerService
     |
     | await
     v
CustomerRepository
     |
     | await
     v
Entity Framework Core
     |
     v
SQL Server

Repository

public async Task<Customer?> GetByIdAsync(
    int id,
    CancellationToken cancellationToken)
{
    return await _context.Customers
        .FirstOrDefaultAsync(
            c => c.Id == id,
            cancellationToken);
}

Service

public async Task<Customer?> GetCustomerAsync(
    int id,
    CancellationToken cancellationToken)
{
    return await _repository.GetByIdAsync(
        id,
        cancellationToken);
}

Controller

[HttpGet("{id}")]
public async Task<IActionResult> GetCustomer(
    int id,
    CancellationToken cancellationToken)
{
    var customer = await _service.GetCustomerAsync(
        id,
        cancellationToken);

    if (customer == null)
        return NotFound();

    return Ok(customer);
}

Why use async at every layer?

The Controller, Service, and Repository can all suspend without blocking a thread while waiting for the database result.

This is called async all the way.

It helps avoid synchronous blocking and improves request throughput under I/O-heavy workloads.

 

10. Important key points for interviews

  1. async and await are language features, not separate threading mechanisms.

  2. The C# compiler typically transforms an async method into a state machine.

  3. IAsyncStateMachine and MoveNext() are important internal implementation concepts.

  4. await checks whether the awaited operation has completed.

  5. If completed, execution continues synchronously.

  6. If incomplete, the state machine saves its state and registers a continuation.

  7. The current thread is not blocked while the asynchronous operation is pending.

  8. A continuation may run on a different thread depending on the synchronization context.

  9. Exceptions are captured in the returned task and propagated when awaited.

  10. async/await is ideal for I/O-bound operations, while Task.Run() is typically used to offload CPU-bound operations.

  11. In ASP.NET Core, use asynchronous APIs throughout the request pipeline where practical.

  12. Avoid blocking async code with .Result, .Wait(), or .GetAwaiter().GetResult().

 

Interview-ready answer

async and await are C# keywords used to implement asynchronous programming. The async keyword enables the use of await, while await allows a method to wait for an asynchronous operation without blocking the calling thread.

Internally, the C# compiler transforms an async method into a state machine that implements IAsyncStateMachine. When an incomplete task is awaited, the state machine saves its execution state, registers a continuation, and returns control to the caller. Once the operation completes, the continuation resumes execution through MoveNext().

In ASP.NET Core Web API, we use async and await for database operations and external API calls to improve thread utilization, responsiveness, and application scalability.

← Back to Articles