Asynchronous programming is fundamental to modern .NET applications.
ASP.NET Core APIs, database access, HTTP clients, messaging systems, file I/O, and background workers all rely heavily on async and await. Small runtime changes in scheduling, continuations, task allocation, synchronization, or I/O handling can therefore affect a large application without any source-code changes.
.NET 11 Preview 6 includes runtime-async performance improvements alongside JIT and other runtime changes. Microsoft has specifically called out "runtime-async performance improvements" as part of the Preview 6 release.
That creates an important engineering problem for teams evaluating a new runtime:
How do you determine whether an async workload actually improved, regressed, or remained unchanged after a .NET upgrade?
The answer is not to compare one stopwatch measurement before and after the upgrade.
A reliable regression investigation requires controlled workloads, repeatable benchmarks, allocation measurements, concurrency testing, production-like I/O, and diagnostics that can identify where the difference originates.
This article presents a practical methodology for diagnosing async performance regressions when moving a .NET application to a newer runtime.
What Counts as an Async Regression?
An async regression is not necessarily a slower await.
It can appear as:
Higher request latency
Lower throughput
Higher CPU consumption
More allocations
Increased garbage collection
Thread-pool starvation
Longer continuation delays
Increased queueing
More time spent waiting on synchronization
Worse tail latency under concurrency
Consider a simple API:
HTTP Request
|
v
Async Controller
|
v
HTTP Client
|
v
Database
|
v
Async Continuation
|
v
HTTP Response
A regression can occur anywhere in this chain.
For example:
.NET 10
p95 = baseline
.NET 11
p95 = higher
That does not prove that await itself became slower.
The cause could instead be:
Runtime
Thread pool
HTTP stack
Database driver
Serialization
GC
Application code
The first task is therefore localization, not optimization.
Establish a Baseline Before Upgrading
Never begin a runtime-regression investigation after the upgrade.
First record the behavior of the current production runtime.
For example:
Runtime: .NET 10
OS: Linux
CPU: Recorded hardware
Build: Release
Workload: HTTP + database
Concurrency: 100
Duration: 5 minutes
Record at least:
| Metric | Baseline |
|---|
| Requests/sec | Measure |
| p50 latency | Measure |
| p95 latency | Measure |
| p99 latency | Measure |
| Error rate | Measure |
| CPU | Measure |
| Memory | Measure |
| Allocations/request | Measure |
| GC activity | Measure |
| Thread-pool behavior | Measure |
These are placeholders for actual measurements.
The purpose is to create a reproducible baseline rather than a generic claim about .NET versions.
Use the Same Application Binary When Possible
One of the most useful techniques is to isolate the runtime from application changes.
Ideally, compare:
Same source
Same dependencies
Same configuration
Same workload
Different runtime
Conceptually:
Application
|
+---- .NET 10 runtime
|
+---- .NET 11 runtime
This makes the comparison much more meaningful than comparing two application versions that contain unrelated code changes.
If the application must be rebuilt for the newer runtime, record the complete build configuration and package versions.
Do not change several variables simultaneously if the goal is to identify a runtime regression.
Start With a Minimal Async Benchmark
Before investigating a complete web application, isolate basic async behavior.
For example:
public static async Task<int> ReturnAsync()
{
await Task.Yield();
return 42;
}
A BenchmarkDotNet benchmark can measure the basic path:
[MemoryDiagnoser]
public class AsyncBenchmarks
{
[Benchmark]
public async Task<int> AsyncMethod()
{
await Task.Yield();
return 42;
}
}
This is not a production workload.
It is a diagnostic baseline.
It helps answer:
Is there a measurable runtime difference
in a minimal asynchronous operation?
If the minimal benchmark is unchanged but the production application regresses, the investigation should move toward application dependencies and workload characteristics.
If the minimal benchmark changes significantly, runtime behavior becomes a stronger candidate for investigation.
Compare Synchronous and Asynchronous Paths
A useful diagnostic is to compare equivalent synchronous and asynchronous workloads.
For example:
public static int Calculate()
{
return 42;
}
public static async Task<int> CalculateAsync()
{
await Task.Yield();
return 42;
}
Benchmark:
[Benchmark]
public int Synchronous()
{
return Calculate();
}
[Benchmark]
public Task<int> Asynchronous()
{
return CalculateAsync();
}
The comparison is not intended to demonstrate that synchronous code is "better."
The purpose is to establish the overhead and behavior of the asynchronous path independently.
For real I/O workloads, the difference will depend heavily on the dependency being awaited.
Test Completed Tasks Separately
Not every asynchronous method actually performs asynchronous work.
Consider:
public Task<int> GetValue()
{
return Task.FromResult(42);
}
This is different from:
public async Task<int> GetValueAsync()
{
await SomeIoOperationAsync();
return 42;
}
Benchmark both.
The first tests an already-completed task path.
The second exercises an actual asynchronous suspension and continuation.
This distinction matters because runtime optimizations may affect these paths differently.
Measure ValueTask Carefully
Some APIs use ValueTask<T> to reduce allocations in scenarios where operations frequently complete synchronously.
For example:
public ValueTask<int> GetCachedValueAsync()
{
return ValueTask.FromResult(42);
}
Compare it with:
public Task<int> GetCachedValueAsync()
{
return Task.FromResult(42);
}
Use:
[MemoryDiagnoser]
public class TaskVsValueTaskBenchmarks
{
[Benchmark]
public Task<int> TaskPath()
=> Task.FromResult(42);
[Benchmark]
public ValueTask<int> ValueTaskPath()
=> ValueTask.FromResult(42);
}
Do not assume ValueTask is always faster.
Its usefulness depends on completion behavior and how the result is consumed.
A benchmark should measure:
Execution time
Allocations
GC activity
Consumer behavior
rather than focusing on the return type alone.
Test Real Suspension Points
The most useful async tests involve actual asynchronous operations.
For example:
public async Task<string> GetDataAsync(
HttpClient client,
CancellationToken cancellationToken)
{
using var response =
await client.GetAsync(
"https://example.test/data",
cancellationToken);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync(
cancellationToken);
}
This introduces several variables:
HTTP connection
Network
Sockets
TLS
Server
Response buffering
Continuation scheduling
A change in end-to-end latency cannot automatically be attributed to the runtime.
Therefore, use both synthetic and real dependency tests.
Use a Controlled HTTP Server
A controlled local server provides a better middle ground.
For example:
Benchmark Client
|
v
Local HTTP Server
|
v
Fixed response delay
The server can intentionally respond after:
0 ms
1 ms
10 ms
50 ms
100 ms
This lets you test how async behavior changes as the workload moves from:
Mostly CPU
to:
Mostly I/O wait
The benchmark should keep the server behavior identical across runtime versions.
Test Different Concurrency Levels
An async regression may not appear at low concurrency.
Test several levels:
1
10
50
100
250
500
1000
The exact levels should reflect the application's expected operating range.
For each level, record:
| Concurrency | Throughput | p50 | p95 | p99 | CPU | Allocations |
|---|
| 1 | Measure | Measure | Measure | Measure | Measure | Measure |
| 10 | Measure | Measure | Measure | Measure | Measure | Measure |
| 50 | Measure | Measure | Measure | Measure | Measure | Measure |
| 100 | Measure | Measure | Measure | Measure | Measure | Measure |
| 500 | Measure | Measure | Measure | Measure | Measure | Measure |
| 1000 | Measure | Measure | Measure | Measure | Measure | Measure |
This can reveal nonlinear behavior.
For example:
Low concurrency
.NET 11 ≈ baseline
High concurrency
.NET 11 → higher p99
That would be a stronger signal than a small difference in a single-thread benchmark.
Watch for Thread-Pool Starvation
Thread-pool starvation is one of the most common causes of async application degradation.
A simplified anti-pattern is:
public string GetData()
{
return GetDataAsync()
.GetAwaiter()
.GetResult();
}
or:
public string GetData()
{
return GetDataAsync().Result;
}
Blocking on asynchronous work can prevent worker threads from making progress efficiently.
Prefer:
public async Task<string> GetDataAsync()
{
return await LoadDataAsync();
}
and propagate asynchrony through the call chain.
When investigating a regression, search for:
.Result
.Wait()
.GetAwaiter().GetResult()
Task.Run used unnecessarily
These patterns may not have changed between runtimes, but a runtime upgrade can expose their effects differently under a particular workload.
Monitor Thread-Pool Counters
During load testing, monitor runtime counters.
The dotnet-counters tool can be used to observe runtime behavior.
For example:
dotnet-counters monitor \
--process-id <PID> \
System.Runtime
Look for indicators related to:
CPU usage
GC activity
ThreadPool activity
Exception rate
Allocation rate
The exact counter set can vary by runtime version.
The important principle is to correlate performance changes with runtime telemetry rather than guessing from request latency alone.
Investigate Continuation Scheduling
An asynchronous method roughly follows:
Start
|
v
Await operation
|
+---- Completed immediately
|
+---- Suspend
|
v
Operation completes
|
v
Continuation
|
v
Resume method
A runtime change can potentially affect different parts of this path.
That is why comparing:
Already completed task
against:
Actually suspended task
is useful.
A minimal benchmark can intentionally force asynchronous suspension:
private static async Task<int> ForceAsync()
{
await Task.Delay(
TimeSpan.FromMilliseconds(1));
return 42;
}
Again, the exact timing should not be interpreted as a runtime benchmark by itself.
The purpose is to isolate the continuation path.
Measure Allocation Behavior
Async state machines can involve allocations depending on the code path and result type.
Use:
[MemoryDiagnoser]
and compare:
Task
ValueTask
Completed operation
Suspended operation
Exception path
Cancellation path
Exception and cancellation paths deserve separate measurements because they can behave very differently from successful execution.
Test Cancellation
Cancellation is an important async behavior that is easy to overlook.
Consider:
public async Task<string> LoadAsync(
CancellationToken cancellationToken)
{
await Task.Delay(
TimeSpan.FromSeconds(10),
cancellationToken);
return "Completed";
}
Test:
using var cts =
new CancellationTokenSource(
TimeSpan.FromMilliseconds(100));
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => LoadAsync(cts.Token));
Measure:
Cancellation latency
Resource cleanup
Exception behavior
Request completion
Connection cleanup
A runtime change that affects cancellation behavior can appear as an application-level timeout problem.
Test Exceptions Separately
Compare:
public static async Task SuccessAsync()
{
await Task.Yield();
}
with:
public static async Task FailureAsync()
{
await Task.Yield();
throw new InvalidOperationException(
"Test failure.");
}
Exception-heavy workloads are not representative of normal success paths, but they are important for systems that legitimately encounter transient failures.
Measure them separately rather than mixing failures into the normal throughput benchmark.
Test Async Streams
IAsyncEnumerable<T> introduces another asynchronous execution pattern.
For example:
public async IAsyncEnumerable<int> GenerateAsync(
[EnumeratorCancellation]
CancellationToken cancellationToken = default)
{
for (var i = 0; i < 100; i++)
{
await Task.Yield();
yield return i;
}
}
Consume it:
await foreach (var item in GenerateAsync(
cancellationToken))
{
Process(item);
}
Benchmark:
Number of elements
Consumer speed
Producer delay
Cancellation
Exception behavior
Memory usage
This can reveal problems that a simple Task<T> benchmark cannot.
Compare Task.WhenAll Workloads
Concurrency patterns can produce very different runtime behavior.
For example:
var tasks = Enumerable
.Range(0, 100)
.Select(LoadAsync)
.ToArray();
await Task.WhenAll(tasks);
Compare it with sequential execution:
foreach (var item in items)
{
await LoadAsync(item);
}
These are intentionally different workloads.
Task.WhenAll introduces concurrency, which makes it useful for identifying:
Thread-pool pressure
Connection-pool limits
Allocation changes
Downstream saturation
Tail latency
A runtime regression may appear only when many continuations are active simultaneously.
Watch for Accidental Serialization
A common async performance mistake is accidentally executing operations sequentially.
For example:
foreach (var item in items)
{
await ProcessAsync(item);
}
may be correct if ordering is required.
But if operations are independent:
var tasks =
items.Select(ProcessAsync);
await Task.WhenAll(tasks);
may provide substantially different throughput.
When comparing runtime versions, make sure the application code has not changed its concurrency model.
Otherwise, you may attribute an application-level difference to .NET.
Benchmark ASP.NET Core Endpoints
For production applications, create an endpoint that exercises realistic async behavior.
app.MapGet(
"/orders/{id:int}",
async (
int id,
IOrderRepository repository,
CancellationToken cancellationToken) =>
{
var order =
await repository.GetAsync(
id,
cancellationToken);
return order is null
? Results.NotFound()
: Results.Ok(order);
});
Then run the same load against:
.NET 10
.NET 11
Keep:
Database
Schema
Queries
Connection pool
Machine
Load generator
Workload
as consistent as possible.
Measure:
Requests/sec
p50
p95
p99
CPU
Memory
GC
Errors
Use Production-Like Database Tests
A database-heavy API can hide runtime behavior behind database latency.
Use a controlled database workload.
For example:
Query A → 1 ms
Query B → 10 ms
Query C → 50 ms
Test each separately.
If the runtime difference appears only with one query, investigate the database provider and query execution path before blaming the runtime.
This is particularly important during major .NET upgrades because dependencies may also have been upgraded.
Separate Runtime and Dependency Changes
A clean upgrade experiment looks like:
Application Version: Same
Dependency Versions: Same
Database: Same
Configuration: Same
Runtime A → .NET 10
Runtime B → .NET 11
A less useful experiment looks like:
.NET 10
EF Core old
HTTP library old
Application old
versus
.NET 11
EF Core new
HTTP library new
Application changed
If performance changes, you cannot identify the cause.
For a real migration, you may eventually need to upgrade dependencies too. But perform a controlled comparison first.
Use Statistical Comparison
One benchmark run is not evidence.
Run enough iterations to understand variation.
For microbenchmarks, BenchmarkDotNet handles warm-up and repeated measurements.
For load tests, repeat each scenario.
For example:
Scenario A
Run 1
Run 2
Run 3
Run 4
Run 5
Then compare distributions rather than individual values.
A practical report might show:
| Runtime | p50 | p95 | p99 | Throughput |
|---|
| Baseline | Measured | Measured | Measured | Measured |
| Candidate | Measured | Measured | Measured | Measured |
The conclusion should include the variability observed across runs.
Define a Regression Threshold
Not every performance difference is meaningful.
Define a threshold before running the experiment.
For example:
Potential regression:
p95 latency > 10% worse
Potential regression:
throughput > 10% lower
Potential regression:
allocation/request > 10% higher
These values are example policy thresholds, not universal standards.
Your organization should choose thresholds based on service-level objectives and measurement noise.
This prevents teams from treating tiny fluctuations as runtime regressions.
Build a Regression Decision Tree
A useful investigation can follow:
Performance changed?
|
v
Reproduce?
|
+-- No → Investigate variance
|
+-- Yes
|
v
Minimal benchmark changes?
|
+-----+-----+
| |
Yes No
| |
v v
Runtime path Application/
likely dependency path
| |
+-----+-----+
|
v
Check CPU / GC / ThreadPool
|
v
Check I/O and dependencies
|
v
Create minimal reproduction
|
v
Compare runtime versions
This prevents premature conclusions.
Create a Minimal Reproduction
If a regression appears real, reduce the application.
For example:
Production API
↓
Remove database
↓
Remove authentication
↓
Remove logging
↓
Remove serialization
↓
Keep async operation
Continue until the performance difference either disappears or becomes reproducible in a small program.
A minimal reproduction is valuable because runtime teams need a narrow workload to investigate a suspected regression.
It also prevents application-specific behavior from being incorrectly reported as a runtime problem.
Common Async Regression Patterns
Blocking Async Code
Search for:
.Result
.Wait()
.GetAwaiter().GetResult()
These can create thread-pool pressure.
Excessive Task.Run
Wrapping naturally asynchronous I/O in Task.Run can add unnecessary scheduling overhead.
For example:
await Task.Run(
() => httpClient.GetStringAsync(url));
is usually unnecessary.
Prefer:
await httpClient.GetStringAsync(url);
Accidental Sequential Processing
Verify whether asynchronous operations that should run concurrently are being awaited one at a time.
Excessive Logging
High-volume asynchronous services can generate substantial logging overhead.
Compare benchmarks with logging controlled consistently.
Connection Pool Exhaustion
An async API can still become bottlenecked by:
HTTP connection pool
Database connection pool
Socket limits
Do not interpret all waiting as thread-pool behavior.
Troubleshooting
Throughput Drops but CPU Is Lower
This can indicate increased waiting rather than CPU saturation.
Investigate:
I/O
Thread-pool availability
Connection pools
Downstream latency
Synchronization
CPU Increases Without More Throughput
Look for:
Additional scheduling
More allocations
GC
Serialization
Logging
Synchronization
Use profiling rather than guessing.
p99 Gets Worse but p50 Is Unchanged
This is a classic tail-latency signal.
Investigate:
Queueing
Thread-pool starvation
Connection-pool limits
GC pauses
Downstream outliers
Only High Concurrency Shows a Difference
This strongly suggests a scalability or contention issue.
Repeat the test at progressively higher concurrency and identify where the curves diverge.
Only the Real Application Regresses
The runtime may not be the direct cause.
Investigate:
Database provider
HTTP stack
Serialization
Middleware
Logging
Thread pool
Application synchronization
The minimal benchmark should help isolate the difference.
Production Migration Checklist
Before upgrading a high-throughput async service:
Record a baseline on the current runtime.
Freeze application and dependency versions for the comparison.
Benchmark basic async operations.
Benchmark real I/O.
Test multiple concurrency levels.
Measure p50, p95, and p99 latency.
Record CPU and memory.
Monitor GC behavior.
Monitor thread-pool behavior.
Test cancellation.
Test exception paths.
Test async streams where applicable.
Test Task.WhenAll workloads.
Test production-like HTTP endpoints.
Test database-backed workloads.
Define regression thresholds.
Repeat suspicious results.
Build a minimal reproduction for confirmed regressions.
Compare dependencies independently.
Document the exact runtime and environment.
Frequently Asked Questions
Does .NET 11 have an async performance regression?
The current official material does not establish a universal regression. .NET 11 Preview 6 explicitly includes runtime-async performance improvements, but performance is workload-dependent. A particular application can still experience a regression because of its workload, dependencies, configuration, or interaction with runtime behavior.
How can I prove that .NET caused the regression?
Use a controlled comparison with the same application, dependencies, configuration, hardware, and workload, changing only the runtime where possible. Then reduce the issue to a minimal reproduction.
Should I benchmark async methods with Task.Delay?
They can be useful for controlled experiments, but Task.Delay is not a substitute for realistic I/O. Use it to isolate scheduling and continuation behavior, then validate findings with real HTTP, database, or messaging workloads.
Should I use BenchmarkDotNet for async testing?
Yes for microbenchmarks. It is useful for comparing focused operations such as task completion, continuations, allocations, and small async methods. For end-to-end service behavior, use a load-testing tool in addition to microbenchmarks.
What metrics matter most?
For production services, prioritize:
Throughput
p95 latency
p99 latency
CPU
Allocations
GC
Error rate
The exact priorities depend on the service's SLOs.
What if .NET 11 is faster on average but slower at p99?
Treat that as a meaningful finding.
Average performance can improve while tail latency gets worse. For user-facing services, the p95/p99 behavior may be more important than the mean.
Conclusion
Runtime upgrades should be treated as measurable engineering changes, not assumptions about performance.
.NET 11 Preview 6 includes runtime-async performance improvements, but the existence of an optimization in the runtime does not guarantee that every application will become faster.
Async workloads are especially difficult to evaluate because their performance depends on more than the runtime:
Application
+
Runtime
+
Thread Pool
+
HTTP / Database
+
Synchronization
+
GC
+
Network
A useful regression investigation therefore starts with a controlled baseline.
Measure the current runtime, keep the application and dependencies stable, vary concurrency systematically, inspect allocations and runtime counters, and reduce confirmed differences to a minimal reproduction.
The most important principle is:
Do not diagnose a runtime regression from one slower request.
Instead:
Baseline
↓
Reproduce
↓
Measure
↓
Isolate
↓
Profile
↓
Compare
↓
Minimize
↓
Validate
That process gives development teams something more valuable than a benchmark number. It gives them evidence about whether a runtime change actually affects their workload and, if it does, where the performance difference originates.