Asynchronous programming is fundamental to modern .NET applications. APIs, database calls, message processing, network clients, and background services all depend heavily on async and await.
Most developers learn a simple rule:
public async Task<Order> GetOrderAsync()
{
return await repository.GetOrderAsync();
}
But not every await is equally necessary.
When an asynchronous method does nothing after awaiting another task, the compiler and runtime can sometimes optimize that pattern. Newer .NET releases continue to improve how asynchronous code is generated and executed, making it increasingly important to measure the actual cost of async state machines rather than relying on assumptions.
This article explores how to benchmark tail-await patterns in .NET 11 and how to determine whether an optimization actually matters in a real application.
What Is a Tail-Await?
Consider this method:
public async Task<string> GetDataAsync()
{
return await LoadDataAsync();
}
The await is the final operation performed by the method.
There is no additional work after the awaited operation:
Call LoadDataAsync()
↓
Await
↓
Return result
Compare that with:
public async Task<string> GetDataAsync()
{
var result = await LoadDataAsync();
return result.Trim();
}
Here, the method must resume after the await and execute additional code.
Call LoadDataAsync()
↓
Await
↓
Resume
↓
Trim result
↓
Return
That distinction is important when investigating compiler and runtime optimizations.
Why Tail-Await Patterns Matter
A large application can contain thousands of small asynchronous methods.
For example:
public async Task<User> GetUserAsync(int id)
{
return await repository.GetUserAsync(id);
}
public async Task<Order> GetOrderAsync(int id)
{
return await service.GetOrderAsync(id);
}
public async Task<bool> IsAvailableAsync(string key)
{
return await cache.IsAvailableAsync(key);
}
Each method looks harmless.
But if these methods sit on high-frequency request paths, small differences in:
can become measurable at scale.
That is why benchmarking is more useful than simply assuming that one syntax is faster.
The Baseline: Direct Task Return
The simplest implementation is often:
public Task<string> GetDataAsync()
{
return LoadDataAsync();
}
There is no async state machine in this method.
The caller receives the task returned by LoadDataAsync() directly.
Conceptually:
Caller
↓
GetDataAsync()
↓
LoadDataAsync()
↓
Task
This is an important baseline because it represents the case where no post-await work is required.
The Awaiting Version
The equivalent asynchronous implementation is:
public async Task<string> GetDataAsync()
{
return await LoadDataAsync();
}
The code is easy to understand, but it introduces an explicit await.
Conceptually:
Caller
↓
GetDataAsync()
↓
Await LoadDataAsync()
↓
Resume
↓
Return
The exact cost depends on compiler/runtime behavior and whether the task completes synchronously or asynchronously.
This is precisely why a benchmark should test both scenarios.
Build a Benchmark Instead of Guessing
A useful benchmark should compare at least:
Direct task return
Tail-await
Await with post-processing
For example:
public class AsyncBenchmarks
{
[Benchmark]
public Task<string> DirectReturn()
{
return GetValueAsync();
}
[Benchmark]
public async Task<string> TailAwait()
{
return await GetValueAsync();
}
[Benchmark]
public async Task<string> AwaitWithWork()
{
var value = await GetValueAsync();
return value.ToUpperInvariant();
}
private static Task<string> GetValueAsync()
{
return Task.FromResult("hello");
}
}
The first two methods represent the most interesting comparison.
The third establishes a control case where post-await work is genuinely required.
Benchmark Synchronous Completion
The first benchmark scenario should use a task that completes immediately:
private static Task<string> GetValueAsync()
{
return Task.FromResult("hello");
}
This matters because many asynchronous operations can complete synchronously.
Examples include:
Cache hits
Already-completed tasks
In-memory operations
Some validation paths
Frequently reused data
A method that looks asynchronous from its API may not actually suspend during every invocation.
Benchmark True Asynchronous Completion
The second scenario should introduce an actual asynchronous boundary.
For example:
private static async Task<string> GetValueAsync()
{
await Task.Yield();
return "hello";
}
Now the benchmark measures a path where the operation genuinely resumes asynchronously.
This produces a different execution pattern:
Method call
↓
Task incomplete
↓
Suspend
↓
Continuation scheduled
↓
Resume
↓
Return
This distinction is critical.
A benchmark that tests only Task.FromResult may not represent the workload of a network-bound application.
Measure Allocations
Latency alone is not enough.
Async performance should also consider allocations.
With a benchmarking framework that supports memory diagnostics, enable allocation measurement:
[MemoryDiagnoser]
public class AsyncBenchmarks
{
// Benchmarks
}
The resulting report can compare:
Method Mean Allocated
DirectReturn ... ...
TailAwait ... ...
AwaitWithWork ... ...
The exact numbers depend heavily on runtime version, hardware, workload, and benchmark configuration.
Do not copy benchmark numbers from another machine and treat them as universal.
Why Allocation Measurements Matter
Suppose a method is called:
1,000 times/second
A small per-call allocation can become significant:
1 byte × 1,000 calls
= 1 KB/sec
At:
100,000 calls/sec
the same per-operation overhead scales dramatically.
Garbage collection then becomes part of the performance equation.
This is why allocation measurements are particularly valuable for high-throughput services.
Compare Different Result Types
Do not benchmark only Task<T>.
Modern .NET applications also use:
Task<T>
ValueTask<T>
Task
ValueTask
A useful experiment can compare:
public Task<string> GetTaskAsync()
{
return GetValueAsync();
}
with:
public async ValueTask<string> GetValueTaskAsync()
{
return await GetValueAsync();
}
However, ValueTask should not automatically be treated as a faster replacement for Task.
It has different usage semantics and is most useful when synchronous completion is common and avoiding allocations matters.
The benchmark should reflect the actual workload.
Avoid Benchmarking Only the Happy Path
A realistic async benchmark should include multiple completion modes.
Scenario 1: Synchronous Completion
Task already completed
Scenario 2: Asynchronous Completion
Task completes later
Scenario 3: Exception
Task faults
Scenario 4: Cancellation
Operation canceled
These scenarios can exercise different code paths.
For example:
private static async Task<string> GetValueAsync(
CancellationToken cancellationToken)
{
await Task.Delay(1, cancellationToken);
return "hello";
}
Now cancellation behavior can be incorporated into the test.
Exceptions Matter Too
Consider:
public async Task<string> GetDataAsync()
{
return await LoadDataAsync();
}
If LoadDataAsync() fails, exception propagation needs to remain equivalent to the optimized implementation.
This is another reason not to benchmark only successful execution.
A performance optimization is not useful if it changes observable behavior.
Tail-Await and Stack Traces
There is another consideration: debugging behavior.
Changing:
public async Task<string> GetDataAsync()
{
return await LoadDataAsync();
}
to:
public Task<string> GetDataAsync()
{
return LoadDataAsync();
}
may change how asynchronous stack traces are represented.
That does not automatically make one version better.
For infrastructure and library code, diagnostics can sometimes be more valuable than a tiny performance improvement.
Benchmarking should therefore include operational considerations.
.NET 11 Makes Measurement More Interesting
Runtime optimization work in newer .NET releases means assumptions based on older runtime versions may become outdated.
This is especially relevant when comparing:
Older .NET runtime
↓
.NET 11
A pattern that previously showed measurable overhead may behave differently after compiler and runtime improvements.
Therefore, benchmarks should compare the actual runtime versions being considered.
For example:
.NET 10
vs
.NET 11
using the same:
Hardware
Benchmark configuration
Workload
Compiler settings
Operating system
Input data
This makes the comparison meaningful.
Benchmark Methodology
A good experiment should control as many variables as possible.
Use:
Same machine
Same OS
Same CPU configuration
Same benchmark code
Same input
Same build configuration
Same runtime architecture
Run enough iterations to reduce noise.
Also perform separate runs for:
Cold startup
Steady-state execution
Synchronous completion
Asynchronous completion
Avoid drawing conclusions from a single benchmark execution.
Example Benchmark Structure
A more complete benchmark class might look like:
using BenchmarkDotNet.Attributes;
[MemoryDiagnoser]
public class AsyncBenchmarks
{
[Benchmark]
public Task<string> DirectReturn()
=> GetCompletedValueAsync();
[Benchmark]
public async Task<string> TailAwait()
=> await GetCompletedValueAsync();
[Benchmark]
public async Task<string> AwaitWithWork()
{
var value = await GetCompletedValueAsync();
return value.ToUpperInvariant();
}
private static Task<string> GetCompletedValueAsync()
=> Task.FromResult("hello");
}
Then create a second benchmark for genuinely asynchronous completion.
This separation prevents a benchmark from mixing fundamentally different execution patterns.
Do Not Use Thread.Sleep in Async Benchmarks
A common mistake is:
Thread.Sleep(1);
inside an asynchronous benchmark.
That blocks the thread and does not represent normal asynchronous I/O.
Prefer actual asynchronous operations:
await Task.Delay(1);
or a controlled asynchronous test double.
The goal is to model asynchronous suspension rather than thread blocking.
Benchmark Realistic Workloads
A microbenchmark can tell you whether two methods differ.
It cannot tell you whether that difference matters to your application.
Suppose a benchmark reports:
TailAwait: 20 ns
Direct: 15 ns
That 5 ns difference may be irrelevant if the real operation performs:
Database call → 5 ms
The correct engineering question is:
Does this optimization matter on a hot path?
For example:
Request
↓
Controller
↓
Service
↓
Repository
↓
Database
If the async wrapper contributes negligible time compared with the database call, readability may be more important.
Where Tail-Await Optimization Can Matter
It becomes more interesting in high-frequency infrastructure code such as:
These methods may execute millions of times and perform very little other work.
In those cases, small overheads can accumulate.
Where It Usually Matters Less
Consider:
public async Task<Order> GetOrderAsync(int id)
{
var order = await database.GetOrderAsync(id);
return order;
}
If the database operation takes several milliseconds, optimizing the wrapper may have little practical impact.
The database call dominates the latency.
In such cases, focus first on:
Micro-optimizations should come after larger bottlenecks have been measured.
Common Mistakes
Assuming async Is Always Expensive
The runtime and compiler have many optimizations. Measure the actual pattern.
Benchmarking Only Completed Tasks
This misses the cost of genuine asynchronous suspension.
Measuring Only Latency
Allocations and GC pressure can be equally important.
Using Unrealistic Delays
A benchmark with arbitrary delays may not represent your production workload.
Optimizing Before Profiling
A micro-optimization is not automatically valuable just because a benchmark can measure it.
Ignoring Diagnostics
A tiny performance improvement may not justify making asynchronous code harder to debug or maintain.
Comparing Different Environments
A benchmark on different CPUs or operating systems can produce misleading conclusions.
Best Practices
Benchmark direct task returns against tail-await implementations.
Test both synchronous and asynchronous completion.
Measure allocations alongside execution time.
Include exception and cancellation scenarios where relevant.
Compare the actual .NET versions used in production.
Keep benchmark environments consistent.
Use realistic workloads before making production changes.
Prefer readability when performance differences are insignificant.
Focus optimizations on high-frequency or latency-sensitive paths.
Re-run benchmarks after runtime upgrades because compiler and runtime behavior can change.
Frequently Asked Questions
Is returning a Task always faster than using await?
Not necessarily in every scenario. Directly returning a task can avoid some async machinery in simple forwarding methods, but compiler and runtime optimizations can reduce differences. Measure the specific workload.
Should every return await be replaced?
No. return await can be useful when additional processing, exception handling, context control, or other behavior is required. Removing it purely for theoretical performance can make code harder to understand.
Does ValueTask automatically improve performance?
No. ValueTask is useful for specific scenarios, particularly where synchronous completion is common. It also has usage constraints that make Task preferable in many APIs.
Should async microbenchmarks use real network calls?
Usually not for the first experiment. Network calls introduce external variability. Start with controlled benchmarks, then validate important conclusions with representative integration workloads.
Does a .NET runtime upgrade invalidate old async benchmarks?
It can. Compiler and runtime optimizations can change performance characteristics. Re-run important benchmarks when moving to a new runtime version.
Conclusion
Asynchronous code is one of the most important performance-sensitive areas in modern .NET applications, but it is also an area where intuition can be misleading.
A tail-await pattern may look unnecessarily expensive:
public async Task<string> GetDataAsync()
{
return await LoadDataAsync();
}
Yet the practical impact depends on how the operation completes, how frequently the method executes, what the runtime optimizes, and what work surrounds the await.
.NET 11 provides another opportunity to measure these assumptions rather than relying on rules from older runtime versions.
A useful benchmarking process is:
Define workload
↓
Build baseline
↓
Test synchronous completion
↓
Test asynchronous completion
↓
Measure latency
↓
Measure allocations
↓
Compare .NET versions
↓
Validate production relevance
The most important lesson is simple: do not optimize async code because it looks expensive; optimize it when measurements show that it is expensive on a meaningful production path.