A key component of contemporary.NET applications is asynchronous programming.

Async and await play a major role in ASP.NET Core APIs, database access, HTTP clients, message systems, file I/O, and background workers. Therefore, a big program might be impacted by small runtime changes in scheduling, continuations, task allocation, synchronization, or I/O processing without requiring any modifications to the source code.NET 11 Along with JIT and other runtime modifications, Preview 6 offers runtime-async performance enhancements. "Runtime-async performance improvements" are particularly mentioned by Microsoft as part of the Preview 6 release.

For teams assessing a new runtime, this poses a significant engineering challenge:
After a.NET upgrade, how can you tell if an async workload truly improved, regressed, or stayed the same?

The solution is to not compare a single stopwatch measurement before and after the update. A meaningful regression study necessitates controlled workloads, repeatable benchmarks, allocation measurements, concurrency testing, production-like I/O, and diagnostics capable of determining the source of the discrepancy.

This article describes a realistic approach to troubleshooting async performance regressions while migrating a.NET application to a newer runtime.

What Constitutes 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:

MetricBaseline
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:

ConcurrencyThroughputp50p95p99CPUAllocations
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:

Runtimep50p95p99Throughput
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.

HostForLIFE.eu ASP.NET Core 10.0 Hosting
European best, cheap and reliable ASP.NET hosting with instant activation. HostForLIFE.eu is #1 Recommended Windows and ASP.NET hosting in European Continent. With 99.99% Uptime Guaranteed of Relibility, Stability and Performace. HostForLIFE.eu security team is constantly monitoring the entire network for unusual behaviour. We deliver hosting solution including Shared hosting, Cloud hosting, Reseller hosting, Dedicated Servers, and IT as Service for companies of all size.