Async Performance

Vaibhav • September 11, 2025

In the previous article, we explored how deadlocks occur in asynchronous programming and how to prevent them using proper design and ConfigureAwait(false). Now that we’ve built a solid foundation for writing safe async code, it’s time to focus on making it fast and efficient. Asynchronous programming isn’t just about responsiveness-it’s also about performance. Poorly written async code can lead to excessive memory usage, thread pool starvation, and sluggish applications.

In this article, we’ll explore how to measure and optimize the performance of asynchronous code in C#. We’ll look at common pitfalls, how the runtime schedules tasks, how to reduce allocations, and how to write async methods that scale well under load. We’ll also cover best practices for minimizing overhead and tuning your code for real-world scenarios.

Understanding Async Overhead

Async methods introduce a small amount of overhead. When you mark a method as async, the compiler transforms it into a state machine that tracks where to resume after each await. This transformation involves allocating objects, capturing contexts, and scheduling continuations.

For most applications, this overhead is negligible. But in high-throughput systems-like web servers, background processors, or real-time apps-it can add up quickly. Understanding where the overhead comes from helps you write leaner async code.

Every await introduces a continuation. If the awaited task is already completed, the continuation runs immediately. Otherwise, it’s scheduled for later-usually on the thread pool.

Minimizing Allocations

One of the biggest performance costs in async code is memory allocation. Every time you use async and await, the compiler generates a state machine object. If your method completes synchronously, this allocation may be unnecessary.


// Avoid this if no await is needed
async Task<int> GetValueAsync()
{
    return 42;
}

// Prefer this
Task<int> GetValueAsync() => Task.FromResult(42);
    

In the first example, the method is marked async but doesn’t use await. This causes unnecessary allocation. The second example returns a completed task directly, avoiding the overhead.

Using ValueTask for Lightweight Results

If your method often returns a result synchronously, consider using ValueTask<T> instead of Task<T>. ValueTask avoids allocating a new task object when the result is already available.


ValueTask<int> GetCachedValueAsync()
{
    return new ValueTask<int>(42); // no allocation
}
    

This method returns a value immediately without allocating a task. If the result is computed asynchronously, you can still wrap a Task inside a ValueTask.

Note: Use ValueTask only when you understand its trade-offs. It’s more complex to use correctly and can lead to subtle bugs if misused.

Avoiding Async Overhead in Hot Paths

In performance-critical code-like tight loops or frequently called methods-avoid marking methods async unless necessary. Every await adds overhead, so keep hot paths synchronous if possible.


void ProcessItems(List<int> items)
{
    foreach (var item in items)
    {
        Process(item); // keep this fast
    }
}
    

If Process doesn’t need to be async, don’t make it async. Reserve async for I/O-bound operations like file access, network calls, or database queries.

Thread Pool Starvation

Async methods rely on the thread pool to resume execution. If you schedule too many tasks at once-especially CPU-bound tasks-you can exhaust the thread pool. This causes delays and poor responsiveness.


for (int i = 0; i < 1000; i++)
{
    Task.Run(() => DoWork()); // risky: floods thread pool
}
    

This code starts 1000 tasks immediately. If DoWork is CPU-bound, the thread pool may not have enough threads to run them all. Use throttling or batching to control concurrency.

Controlling Concurrency with SemaphoreSlim

To avoid thread pool starvation, use SemaphoreSlim to limit the number of concurrent tasks. This keeps your system responsive and prevents overload.


SemaphoreSlim semaphore = new SemaphoreSlim(10); // max 10 tasks

async Task ProcessAsync()
{
    await semaphore.WaitAsync();

    try
    {
        await DoWorkAsync();
    }
    finally
    {
        semaphore.Release();
    }
}
    

This pattern ensures that no more than 10 tasks run at once. It’s ideal for processing queues, handling requests, or managing background jobs.

Avoiding Context Capture

Capturing the synchronization context adds overhead. In library or background code, use ConfigureAwait(false) to avoid it. This improves performance and reduces contention.


await Task.Delay(1000).ConfigureAwait(false);
    

This tells the runtime not to resume on the original context. In UI apps, you need context capture to update controls. But in other code, it’s usually unnecessary.

Measuring Async Performance

To optimize async code, you need to measure it. Use tools like:

BenchmarkDotNet - for micro-benchmarking async methods. dotnet-trace - for tracing async call stacks. PerfView - for analyzing allocations and thread usage. Visual Studio Profiler - for inspecting CPU and memory usage.

Measure before and after changes. Focus on throughput, latency, and memory usage. Look for excessive allocations, long-running tasks, and thread pool saturation.

Avoiding Async Void

async void methods are hard to measure, debug, and optimize. They don’t return a task, so you can’t await them or catch exceptions. Use async Task instead.


// Bad
async void DoSomething() { ... }

// Good
async Task DoSomethingAsync() { ... }
    

This makes your code testable, composable, and easier to profile. Reserve async void for event handlers only.

Avoiding Unnecessary Async

Not every method needs to be async. If your method doesn’t perform asynchronous work, don’t mark it async. This avoids unnecessary state machines and improves performance.


// Avoid this
async Task<int> AddAsync(int a, int b)
{
    return a + b;
}

// Prefer this
Task<int> AddAsync(int a, int b) => Task.FromResult(a + b);
    

This pattern keeps your code lean and avoids overhead. Use async only when you need await.

Common Mistakes

One common mistake is using async everywhere without understanding the cost. This leads to excessive allocations and poor performance. Another mistake is flooding the thread pool with tasks. Use throttling and batching to control concurrency. Also, avoid blocking calls like .Result or .Wait(). They freeze threads and cause deadlocks.

You can use Task.CompletedTask to return a completed Task without allocating a new one. It’s perfect for methods that complete immediately.

Summary

Async performance is about writing code that’s not only responsive but also efficient. You’ve learned how async methods work under the hood, how to minimize allocations, avoid thread pool starvation, and use tools like ValueTask and SemaphoreSlim to optimize your workflows. You’ve also seen how to measure performance and avoid common pitfalls like unnecessary async, blocking calls, and context capture.

With these techniques, you can write async code that scales gracefully, runs fast, and keeps your applications smooth and responsive. This concludes Chapter 17 on Asynchronous Programming. In the next chapter, we’ll begin exploring advanced topics like concurrency, threading, and parallelism in more depth.