acuamitca.com logo ACUAMITCA
~/blog/article

Mastering Async/Await in C#

Technical Article 0 mins read July 20, 2026

Async/Await in Production: The Mental Model That Matters

The async and await keywords make asynchronous C# code look almost synchronous. That is their greatest strength, but it is also why they are frequently misunderstood. Developers can use the syntax successfully for years without knowing when a thread is released, where a continuation runs, why a deadlock occurs, or why an apparently harmless Task.Run can reduce throughput in a web application.

For enterprise systems, those details matter. A service may spend most of its lifetime waiting for SQL Server, an HTTP API, Azure Storage, a message broker, or an ERP endpoint. When waiting is handled asynchronously, the application can serve more work with fewer blocked threads. When it is handled poorly, the result can be thread-pool starvation, unpredictable latency, hidden exceptions, or processes that cannot shut down cleanly.

This guide focuses on the mental model and patterns that matter in production. The examples apply to ASP.NET Core APIs, background workers, desktop applications, integrations, and Acumatica-related services written in modern C#.


1. Why Asynchronous Programming Exists

Asynchronous programming is mainly about efficient waiting. It does not automatically make an individual operation complete faster. Instead, it prevents an application thread from remaining blocked while an external system performs work.

A database query may take 200 milliseconds, but the CPU might only be active for a tiny fraction of that time. In a synchronous implementation, the request thread waits for the database. In an asynchronous implementation, the thread can return to the pool and process other work until the query completes.

Key Distinction

Use asynchronous APIs for I/O-bound work. Use parallelism or carefully measured Task.Run usage for CPU-bound work. Async is not a synonym for parallel.

Synchronous I/O

public Customer LoadCustomer(int id)
{
    return repository.GetById(id);
}

Asynchronous I/O

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

The second method does not promise to create another thread. It promises that the caller receives a Task representing the eventual result. If the underlying operation is incomplete, the method can return control without blocking the current thread.


2. What Async and Await Actually Do

The async keyword enables await inside a method and instructs the compiler to transform that method. The await operator examines an awaitable value, commonly Task or Task<T>. If the operation has already completed, execution continues immediately. If it has not, the method records its current state, arranges a continuation, and returns to its caller.

public async Task<Order> GetOrderAsync(int orderId, CancellationToken cancellationToken)
{
    Order order = await repository.GetAsync(orderId, cancellationToken);
    order.MarkAsViewed();
    return order;
}

The code after await is the continuation. It may run later and, depending on the environment, may run on a different thread. Correct asynchronous code should normally depend on logical execution flow, not a specific thread identity.


3. The Compiler-Generated State Machine

An async method is rewritten into a state machine. Local variables that must survive an await are stored as fields in that generated structure. The compiler also creates a builder responsible for completing the returned Task and a MoveNext method that advances execution through the states.

You do not normally need to inspect this generated code, but the model explains several important behaviors: an async method starts synchronously, code runs until the first incomplete await, exceptions before and after an await are captured into the returned Task, and unnecessary async wrappers can add state-machine and allocation overhead.

// Useful when no additional asynchronous work is required.
public Task<Customer> GetCustomerAsync(int id, CancellationToken cancellationToken)
{
    return repository.GetAsync(id, cancellationToken);
}

// Useful when work must happen after the await.
public async Task<Customer> GetValidatedCustomerAsync(int id, CancellationToken cancellationToken)
{
    Customer customer = await repository.GetAsync(id, cancellationToken);
    Validate(customer);
    return customer;
}

4. I/O-Bound Work versus CPU-Bound Work

The correct implementation depends on what the method is doing. I/O-bound operations wait for an external resource. CPU-bound operations actively consume processor time.

Work Type Examples Preferred Approach
I/O-bound SQL, HTTP, files, queues, cloud storage Use native asynchronous APIs and await them.
CPU-bound Image processing, compression, calculations Run synchronously or offload deliberately when responsiveness requires it.
Mixed Read data, transform it, then save Await I/O; measure and isolate expensive CPU work.

Warning: Wrapping an already asynchronous database or HTTP call in Task.Run consumes an additional thread and usually makes scalability worse. Task.Run is appropriate when genuinely CPU-bound work must be moved away from a UI thread, or when a controlled background workflow intentionally schedules CPU work.


5. The SynchronizationContext and Deadlocks

Some application models install a SynchronizationContext that controls where continuations run. Traditional UI frameworks use this to resume code on the UI thread. Older ASP.NET also used a request context. ASP.NET Core does not install the same kind of request SynchronizationContext, which removes a common source of deadlocks, but blocking on asynchronous code is still a poor practice.

// Dangerous: blocks the current thread while waiting for async work.
Customer customer = service.GetCustomerAsync(id, CancellationToken.None).Result;

// Also dangerous.
service.SaveAsync(customer, CancellationToken.None).Wait();

// Correct: async all the way.
Customer customer = await service.GetCustomerAsync(id, cancellationToken);
await service.SaveAsync(customer, cancellationToken);

A classic deadlock occurs when a thread blocks on Result or Wait while the awaited continuation needs to return to that same context. Even where a deadlock does not occur, synchronous blocking wastes threads and can produce thread-pool starvation under load.


6. ConfigureAwait(false)

ConfigureAwait(false) tells an await not to request resumption on a captured context. It is valuable in reusable library code because the library usually does not care about a UI or request context. It can also avoid unnecessary context scheduling.

public async Task<string> DownloadDocumentAsync(Uri uri, CancellationToken cancellationToken)
{
    using HttpResponseMessage response = await httpClient
        .GetAsync(uri, cancellationToken)
        .ConfigureAwait(false);

    response.EnsureSuccessStatusCode();

    return await response.Content
        .ReadAsStringAsync(cancellationToken)
        .ConfigureAwait(false);
}

Application code should use it intentionally rather than mechanically. UI code may need to continue on the captured context before updating controls. ASP.NET Core application code generally has no request SynchronizationContext to capture, so ConfigureAwait(false) often changes little there. Library code remains the clearest use case.


7. Task.Run: Useful Tool or Code Smell?

Task.Run queues work to the thread pool. It is useful for CPU-bound work that should not block a responsive UI, and it can be useful in carefully designed orchestration code. It does not convert synchronous I/O into scalable asynchronous I/O.

// Avoid in an ASP.NET Core request when the repository already supports async.
Customer customer = await Task.Run(
    () => repository.GetById(customerId),
    cancellationToken);

// Prefer the real asynchronous API.
Customer customer = await repository.GetByIdAsync(
    customerId,
    cancellationToken);

In a server process, every Task.Run operation still requires a thread-pool thread. Under sustained load, unnecessary offloading can increase context switching and delay unrelated requests.


8. CancellationToken Done Correctly

Cancellation is cooperative. A token communicates that the caller no longer needs the result; the operation must observe the token and stop at a safe point. Cancellation is especially important for web requests, long integrations, batch processes, and application shutdown.

public async Task<InvoiceResult> ProcessInvoiceAsync(
    Invoice invoice,
    CancellationToken cancellationToken)
{
    cancellationToken.ThrowIfCancellationRequested();

    Customer customer = await customerClient.GetAsync(
        invoice.CustomerId,
        cancellationToken);

    InvoiceResult result = BuildResult(invoice, customer);

    await repository.SaveAsync(result, cancellationToken);
    return result;
}

Pass the token through every layer that supports cancellation. Do not replace a caller-provided token with CancellationToken.None. Avoid swallowing OperationCanceledException unless you are translating it into a well-defined higher-level outcome.


9. Running Independent Operations Concurrently

Sequential awaits are correct when one operation depends on the previous result. Independent operations can often be started together and awaited with Task.WhenAll, reducing total latency.

Task<Customer> customerTask = customerClient.GetAsync(
    customerId,
    cancellationToken);

Task<IReadOnlyList<Order>> ordersTask = orderClient.GetRecentAsync(
    customerId,
    cancellationToken);

await Task.WhenAll(customerTask, ordersTask);

Customer customer = await customerTask;
IReadOnlyList<Order> orders = await ordersTask;

This is concurrency, not necessarily parallel CPU execution. Each I/O operation can progress independently while the request thread remains available. Do not start unlimited work from an unbounded collection; use throttling when calling external services or processing large batches.

var options = new ParallelOptions
{
    MaxDegreeOfParallelism = 8,
    CancellationToken = cancellationToken
};

await Parallel.ForEachAsync(items, options, async (item, token) =>
{
    await processor.ProcessAsync(item, token);
});

10. Exception Handling in Asynchronous Code

Exceptions thrown by an awaited operation are rethrown at the await point. This means ordinary try/catch blocks work naturally. The important requirement is that the Task must be awaited or otherwise observed.

try
{
    await integrationClient.PushAsync(payload, cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
    logger.LogInformation("Integration was cancelled by the caller.");
    throw;
}
catch (HttpRequestException ex)
{
    logger.LogError(ex, "The integration endpoint could not be reached.");
    throw;
}

When Task.WhenAll fails, awaiting it throws an exception from the group. The returned Task still contains the complete failure state. Log enough context to identify each operation, and design retries around transient failures rather than retrying every exception indiscriminately.


11. Fire-and-Forget Is Rarely Harmless

Starting a Task without awaiting it makes ownership unclear. The request may finish, scoped dependencies may be disposed, the process may shut down, and exceptions may go unobserved. This is particularly dangerous in ASP.NET Core request handlers.

// Risky: no owner, no result, no reliable exception handling.
_ = emailSender.SendAsync(message, cancellationToken);

// Better: queue durable background work.
await backgroundQueue.EnqueueAsync(
    new SendEmailCommand(message),
    cancellationToken);

For background work, use a hosted service, a durable message queue, a scheduler, or an explicit task-tracking component. The initiating code should know whether the work must complete, can be retried, and can survive a process restart.


12. Task versus ValueTask

Task is the default return type for asynchronous operations. ValueTask<T> can reduce allocations when an operation frequently completes synchronously, but it introduces usage constraints and additional complexity. It should be selected after measurement, not as a general optimization.

public ValueTask<Customer?> TryGetCachedCustomerAsync(
    int customerId,
    CancellationToken cancellationToken)
{
    if (cache.TryGetValue(customerId, out Customer? customer))
    {
        return ValueTask.FromResult(customer);
    }

    return new ValueTask<Customer?>(
        repository.GetByIdAsync(customerId, cancellationToken));
}

A ValueTask should normally be awaited once. Converting it to a Task or using it multiple times can remove its benefit or violate the contract of its underlying source. For public APIs, Task is usually simpler and safer unless profiling shows a meaningful allocation problem.


13. Common Production Mistakes

Blocking with .Result or .Wait()

This risks deadlocks, wastes threads, and breaks the async call chain.

Using async void

Outside event handlers, async void prevents callers from awaiting completion and handling exceptions.

Wrapping I/O in Task.Run

This consumes a worker thread instead of using true asynchronous I/O.

Ignoring Cancellation

Abandoned requests continue using database, network, and CPU resources.

Sequentially Awaiting Independent Calls

This increases latency when operations could safely overlap.

Starting Unbounded Concurrent Work

This can overload databases, APIs, sockets, and memory.

Not Awaiting a Task

Exceptions and completion become detached from the caller.

Adding async Without Need

An unnecessary async wrapper can add a state machine when directly returning the Task is sufficient.


14. Production-Ready Service Example

The following service demonstrates cancellation propagation, concurrent independent I/O, clear exception ownership, and no unnecessary Task.Run usage.

public sealed class CustomerSummaryService
{
    private readonly ICustomerClient customerClient;
    private readonly IOrderClient orderClient;
    private readonly ILogger<CustomerSummaryService> logger;

    public CustomerSummaryService(
        ICustomerClient customerClient,
        IOrderClient orderClient,
        ILogger<CustomerSummaryService> logger)
    {
        this.customerClient = customerClient;
        this.orderClient = orderClient;
        this.logger = logger;
    }

    public async Task<CustomerSummary> GetAsync(
        int customerId,
        CancellationToken cancellationToken)
    {
        Task<Customer> customerTask = customerClient.GetAsync(
            customerId,
            cancellationToken);

        Task<IReadOnlyList<Order>> ordersTask = orderClient.GetRecentAsync(
            customerId,
            cancellationToken);

        try
        {
            await Task.WhenAll(customerTask, ordersTask);
        }
        catch (OperationCanceledException)
            when (cancellationToken.IsCancellationRequested)
        {
            logger.LogInformation(
                "Customer summary request {CustomerId} was cancelled.",
                customerId);
            throw;
        }

        return new CustomerSummary(
            await customerTask,
            await ordersTask);
    }
}

15. Practical Best-Practice Checklist

  • Use async all the way from the entry point to the I/O operation.
  • Prefer native asynchronous APIs over Task.Run wrappers.
  • Pass CancellationToken through every layer that supports it.
  • Await every Task unless a dedicated background-work owner tracks it.
  • Run independent I/O operations concurrently, but limit concurrency.
  • Use Task as the default return type; use ValueTask only after measurement.
  • Avoid async void except for framework event handlers.
  • Do not use .Result, .Wait(), or GetAwaiter().GetResult() in normal application flow.
  • Keep asynchronous methods focused and name them with the Async suffix.
  • Measure throughput, latency, allocations, and external-system limits before optimizing.

Frequently Asked Questions

Does async create a new thread?

No. An async method can complete without creating a new thread. During asynchronous I/O, the current thread is normally released while the operating system or external resource completes the operation.

Is async code always faster?

No. Async improves responsiveness and scalability when code spends time waiting. It may add small overhead for operations that complete immediately or for CPU-bound work.

Should every method return Task?

No. Use Task when the method is genuinely asynchronous or must compose asynchronous operations. Synchronous methods should remain synchronous.

Should I always use ConfigureAwait(false)?

No. It is most appropriate in reusable library code that does not require the caller context. Application code should use it based on the execution environment and context requirements.

When should I use Task.Run?

Use it deliberately for CPU-bound work that should run on a thread-pool thread, especially to preserve UI responsiveness. Avoid using it to wrap I/O that already has an asynchronous API.

Why should CancellationToken be the last parameter?

It is a common .NET convention that improves consistency and makes optional cancellation easier to recognize across APIs.


Conclusion

Async and await are not merely syntax for running work in the background. They are a structured way to represent incomplete operations, release threads during I/O waits, and compose scalable workflows. The most reliable code follows a few consistent rules: use native asynchronous APIs, avoid blocking, propagate cancellation, observe every Task, and introduce concurrency only where operations are independent and external limits are respected.

Once this mental model is clear, asynchronous code becomes easier to reason about. You can recognize where threads are being wasted, where latency can be reduced, and where hidden lifetime or exception problems may appear before they reach production.