European ASP.NET 4.5 Hosting BLOG

BLOG about ASP.NET 4, ASP.NET 4.5 Hosting and Its Technology - Dedicated to European Windows Hosting Customer

European ASP.NET Core 10.0 Hosting - HostForLIFE :: System Design's Availability: What Takes Place When Something Fails?

clock September 28, 2026 11:42 by author Peter

Phase 01 — System Design Fundamentals | Topic 11
Imagine your application is working perfectly.

  • Customers can log in.
  • Orders can be placed.
  • Payments are processed.

Then suddenly, one application server stops working.

What happens next?
If the entire application becomes unavailable, we have a design problem.
This is where Availability becomes an important part of System Design.

What Is Availability?
Availability describes the ability of a system to remain accessible and operational when users need it.

In simple words:
Availability means the system should continue serving users when possible, even when some components fail.

A system does not need to be available 100% of the time in every situation.
Instead, the business usually defines an expected availability level based on the importance of the application.

For example, a payment system may require much higher availability than an internal reporting application.

Why Does Availability Matter?

Users expect applications to be available when they need them.

Imagine a customer trying to place an order and receiving:
    “Service unavailable.”

Even if the problem lasts only a few minutes, it can affect customer experience and business operations.

For critical systems, downtime can also cause:

  • Lost transactions
  • Lost revenue
  • Operational delays
  • Customer dissatisfaction

This is why availability should be considered during system design rather than only after deployment.

What Happens When a Component Goes Down?
Consider a very simple architecture:
Users
   ↓
Web Server
   ↓
Application Server
   ↓
Database

Now imagine the web server fails.

Users cannot reach the application even if the application server and database are still working.

This web server has become a Single Point of Failure.

What Is a Single Point of Failure?

A Single Point of Failure, or SPOF, is a component whose failure can cause the entire system or a critical part of it to become unavailable.

For example:
Users
   ↓
One Server
   ↓
Application
   ↓
Database


If that one server fails, the system may become unavailable.

The problem is not that the server can fail.

Servers can fail.

The problem is that the architecture has no alternative path.

This leads to an important System Design principle:

Do not depend on a single component when its failure can stop the entire service.

How Do We Improve Availability?

One common approach is redundancy.

Instead of running one application server, we can run multiple instances.

For example:

             ┌── Application Server 1
Users → Load Balancer
             └── Application Server 2

Now, if one server fails, the other server may continue handling requests.

This is the basic idea behind designing systems that can tolerate individual component failures.
What Is Redundancy?

Redundancy means having additional instances or components available so that the system does not depend on only one resource.

For example, instead of:

    One application server

we might have:

    Two or more application servers.

Instead of relying on one critical component, we create alternatives.

Redundancy can be used at different levels of a system, including application servers, databases, network components, and other infrastructure.

The exact level of redundancy depends on the business requirements and the expected failure scenarios.
What Does a Load Balancer Do?

When multiple application servers exist, we need some way to distribute incoming requests.

A load balancer can sit in front of those servers and distribute traffic between them.

A simplified design looks like:

Users
   ↓
Load Balancer
   ↓
---------------------
↓                   ↓
Server 1          Server 2

If both servers are healthy, requests can be distributed between them.

If one server becomes unhealthy, the load balancer can stop sending new requests to that server, depending on the configured health-check and routing behavior.

This improves availability because the application does not depend on a single server.
What Is Failover?

Failover means moving processing to another available component when the primary component fails.

For example:

Primary Server
      ↓
    Fails
      ↓
Backup Server
      ↓
Continues Service

Failover can be automatic or manual depending on the system.

For important production systems, automatic failover is often preferred because it can reduce the time users are affected by a failure.
Health Checks Are Important

How does the system know that a component is unhealthy?

One common approach is a health check.

The application can expose an endpoint such as:

GET /health

The platform or load balancer can periodically check that endpoint.

If the service is not healthy, traffic can be redirected away from that instance.

Health checks are therefore an important part of availability design.
Availability Is Not Only About Servers

Developers sometimes think:

    “Just add multiple servers and the system is highly available.”

Not necessarily.

Imagine you have two application servers, but both depend on one database.

        Load Balancer
        /           \
   Server 1       Server 2
        \           /
          Database

If the database becomes unavailable, both application servers may still be unable to process requests.

The database has now become a potential Single Point of Failure.

This is why availability needs to be considered across the whole system, not just one layer.
A Practical Example

Suppose you are designing an e-commerce application.

A basic design may look like:

Users
   ↓
Single Application Server
   ↓
Single Database

This is simple, but there are clear failure points.

A more resilient design could use:

                 ┌── Application Server 1
Users → Load Balancer
                 └── Application Server 2
                         ↓
                   Highly Available
                      Database

Now the system has more than one application instance, and the database can also be designed with appropriate redundancy depending on the chosen database platform.

The architecture is more complex, but that complexity exists for a reason: reducing the impact of failures.
Availability vs “Nothing Will Fail”

A common misunderstanding is:

    “A highly available system never fails.”

That is not realistic.

Components can fail.

Networks can fail.

Databases can become unavailable.

External services can stop responding.

The goal of availability is not to prevent every failure.

The goal is to reduce the impact of failures and keep the service available whenever possible.

This is a much more practical way to think about production systems.
How Should You Think About Availability?

When designing a system, ask:

    What happens if this component stops working?

Then continue:

    Is there another component that can take over?

And:

    How will the system detect the failure?

And finally:

    How quickly can the system recover?

These questions naturally lead to design decisions involving:

    Redundancy

    Load balancing

    Health checks

    Failover

    Monitoring

    Alerts

    Backup strategies

The exact solution depends on the system's requirements and constraints.
IMPORTANT Takeaway

A production system should be designed with the possibility of failure in mind.

Do not assume:

    “This server will always be available.”

Instead, ask:

    “What happens when this server goes down?”

That one question can reveal Single Points of Failure and lead to better architecture.

A simple way to remember it is:

  • Availability is not about preventing every failure.
  • It is about keeping the system usable when failures happen.

Final Thoughts
When you design an application, it is easy to focus on features and happy-path scenarios.
System Design asks us to think about something else:

What happens when something goes wrong?
If one server fails, can another handle the traffic?
If a database becomes unavailable, what happens to the application?
If an instance becomes unhealthy, can the system detect it?

The answers to these questions shape the availability of the system.

That is why availability should be considered from the beginning of the architecture, not after the first production failure.

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.



European ASP.NET Core 10.0 Hosting - HostForLIFE :: Ten Bottlenecks in ASP.NET Core API Performance and How to Identify Them

clock September 25, 2026 12:17 by author Peter

Even with typical CPU and memory use, an ASP.NET Core API may nevertheless feel sluggish. Troubleshooting that is one of the more annoying issues with API performance. You examine the server. CPU appears to be in good condition. Memory appears to be in good condition. The database is accessible. Infrastructure breakdowns are not readily apparent.

However, an endpoint that ought to react within 100–200 ms is taking 1-2 seconds.

The explanation is that CPU and memory are not the only factors that affect API latency.

A request can spend most of its time waiting for a database query, another API, a thread, a lock, serialization, or some application code that is doing more work than expected.

The first question I ask when troubleshooting a slow ASP.NET Core API is:

Where is the request actually spending its time?
This article walks through 10 common ASP.NET Core performance bottlenecks and, more importantly, how to identify them before changing the code.

1. Start With Measurement, Not Optimization
Before changing a LINQ query, adding caching, or increasing server resources, establish where the latency comes from.

A useful way to think about an API request is:
Total Request Time
        |
        +-- Application code
        +-- Database
        +-- External APIs
        +-- Serialization
        +-- Network
        +-- Middleware
        +-- Thread scheduling / blocking


For example, suppose an endpoint takes 1.4 seconds:
Application code       120 ms
SQL queries            180 ms
External API           900 ms
JSON serialization      80 ms
Other                   120 ms
--------------------------------
Total                 1400 ms


Optimizing the 180 ms database operation will not solve the main problem.

The external API is responsible for most of the latency.
This is why performance troubleshooting should generally follow:

Measure → Trace → Identify → Fix → Measure again

Useful metrics include:

  • Average response time
  • P95 latency
  • P99 latency
  • Requests per second
  • Database query duration
  • Number of database calls
  • External dependency latency
  • Exception rate
  • Thread-pool behavior
  • Response payload size

Average latency is useful, but P95 and P99 can reveal problems that average measurements hide.

2. Slow SQL Queries

The database is one of the first places to investigate when an ASP.NET Core API becomes slow.

A query that performs well with 10,000 rows in development may behave very differently when the production database contains millions of records.

Common causes include:

  • Missing indexes
  • Inefficient joins
  • Full table scans
  • Large result sets
  • Selecting unnecessary columns
  • Poorly written LINQ queries
  • Repeated database calls
  • Expensive execution plans

For example:
var customers = await db.Customers
    .Where(x => x.Status == "Active")
    .ToListAsync();

The LINQ itself doesn't tell you whether the generated SQL is efficient.

You need to inspect the SQL and execution plan.

With EF Core, you can inspect generated SQL during development:
var query = db.Customers
    .Where(x => x.Status == "Active");

Console.WriteLine(query.ToQueryString());


Then check the actual database behavior.

Look at:

  • Execution plan
  • Index usage
  • Query duration
  • Number of rows scanned
  • Number of rows returned
  • Database round trips

A useful rule is:
Don't optimize the C# code around a slow query until you know how much time the database is actually consuming.

3. EF Core N+1 Queries

The N+1 query problem is particularly easy to introduce when working with Entity Framework Core.

Imagine an endpoint retrieves 100 customers:
1 query → Get 100 customers
100 queries → Get details for each customer
Total = 101 database queries


The API may work correctly, but the number of database round trips can quickly become a performance problem.

A simplified example looks like this:
var customers = await db.Customers
    .ToListAsync();

foreach (var customer in customers)
{
    var orders = await db.Orders
        .Where(x => x.CustomerId == customer.Id)
        .ToListAsync();
}


With 100 customers, this can result in 101 database calls.

One approach is to project only the data required by the API:
var customers = await db.Customers
    .Select(c => new CustomerDto
    {
        Id = c.Id,
        Name = c.Name,
        Email = c.Email
    })
    .ToListAsync();

Depending on the relationship and use case, eager loading, projections, joins, or specifically designed queries may be appropriate.
The important part is not simply changing the LINQ.
Measure the generated SQL and verify how many database calls the endpoint actually makes.

4. Blocking Calls and Thread Pool Starvation

ASP.NET Core is designed around asynchronous I/O, but blocking calls can still appear in application code.

For example:
var result = service.GetDataAsync().Result;

or:

service.GetDataAsync().Wait();

These calls can block a thread while waiting for I/O.
Under light traffic, the problem may not be obvious.
Under higher concurrency, blocked threads can accumulate and contribute to thread-pool starvation.

Possible symptoms include:

  • Increasing response times
  • Requests waiting in queues
  • Reduced throughput
  • Timeouts
  • Increasing thread counts
  • CPU usage that does not necessarily look high

Prefer asynchronous code throughout the request path:

var result = await service.GetDataAsync();

Also look for:

.Result
.Wait()
Blocking locks
Synchronous I/O
Blocking third-party libraries

For runtime investigation, tools such as dotnet-counters and dotnet-trace can help identify thread-pool and runtime behavior.

The important point is that low CPU utilization does not automatically mean the application is healthy.

An application can be waiting rather than computing.

5. Slow External API Calls

Your ASP.NET Core application may be fast while one of its dependencies is slow.

Consider an endpoint that calls three services:
Customer API      150 ms
Payment API       300 ms
Shipping API      800 ms
--------------------------------
Total           1,250 ms

Your own application code may execute in only a small fraction of that time.

The user still waits 1.25 seconds.

This is why distributed tracing becomes important in applications with multiple dependencies.

For outbound HTTP calls, consider:

IHttpClientFactory

  • Async HTTP calls
  • Connection reuse
  • Appropriate timeouts
  • Limited retries
  • Circuit breakers
  • Caching where appropriate
  • Distributed tracing

For example:
public async Task<Customer> GetCustomerAsync(
    int customerId,
    CancellationToken cancellationToken)
{
    return await httpClient.GetFromJsonAsync<Customer>(
        $"customers/{customerId}",
        cancellationToken);
}


Also be careful with retries.

A retry strategy that blindly retries every failure can increase traffic against an already unhealthy dependency.

6. Large API Responses and JSON Serialization

Sometimes the API isn't slow because it is doing too much work.

It is slow because it is returning too much data.

An endpoint that originally returned a 20 KB response may eventually return hundreds of KB—or several MB—as new fields and relationships are added.

Large payloads affect:

  • JSON serialization
  • Network transfer
  • Memory usage
  • Client-side processing
  • Mobile application performance

Instead of returning an entire entity:
return Ok(customer);

consider using a DTO designed specifically for the endpoint:
var result = await db.Customers
    .Select(c => new CustomerResponse
    {
        Id = c.Id,
        Name = c.Name,
        Email = c.Email
    })
    .ToListAsync();

Other useful techniques include:

  • Pagination
  • Filtering
  • Sorting
  • Field selection
  • Compression where appropriate
  • Avoiding unnecessary nested objects

A smaller response is often easier to process at every layer.

7. Poor Caching Strategy

Caching can reduce database calls and repeated computation, but adding a cache does not automatically make an API faster.
First identify whether the data is a good candidate for caching.

For example:
Frequently requested
+
Expensive to calculate
+
Doesn't change frequently
=
Good caching candidate


Depending on the application, you may use:

  • IMemoryCache
  • Distributed caching
  • Response caching
  • Redis
  • Application-level caching

But caching introduces its own questions:

  • How long should the data remain cached?
  • When should it be invalidated?
  • Is stale data acceptable?
  • Is the data sensitive?
  • Does every application instance need the same cached value?

A poorly designed cache can create consistency problems or add unnecessary infrastructure.
Cache deliberately, not automatically.

8. Database and HTTP Connection Management

Connection management problems can remain hidden during development and become visible under production traffic.
One common HTTP mistake is repeatedly creating HttpClient instances instead of using IHttpClientFactory.

For example, avoid creating clients unnecessarily for every request:
using var client = new HttpClient();


For ASP.NET Core applications, IHttpClientFactory provides a better approach to managing HTTP clients and handlers.

Example:
builder.Services.AddHttpClient<PaymentClient>(client =>
{
    client.BaseAddress = new Uri("https://api.example.com/");
    client.Timeout = TimeSpan.FromSeconds(10);
});


Connection management can affect:

  • Connection reuse
  • Socket usage
  • Connection establishment time
  • API latency
  • Reliability under load

The same principle applies to database connections.

Use the connection pooling and lifecycle management provided by your database provider and ORM rather than manually creating unnecessary connections.

9. Excessive Middleware and Logging
Logging is essential for production troubleshooting.
But logging everything on every request can become expensive.
For example, logging large request and response objects can involve:

Build log message
      ↓
Serialize object
      ↓
Write log
      ↓
Send to logging platform
      ↓
Store and process

When an API handles thousands of requests per minute, that additional work can become noticeable.

Review:

  • Log levels
  • Request/response logging
  • Large object logging
  • Duplicate logs
  • Middleware that runs on every request

The goal isn't to remove logging.

The goal is to make logging useful without adding unnecessary work to the request path.

The same applies to middleware.

If every request passes through many expensive middleware components, the overhead can accumulate.

Measure the middleware pipeline before removing components blindly.

10. Inefficient Application Logic

Not every slow ASP.NET Core API has a database problem.
Sometimes the bottleneck is simply application code.

Common examples include:

  • Expensive loops
  • Repeated LINQ operations
  • Processing large collections in memory
  • Repeated calculations
  • Excessive object allocation
  • Unnecessary transformations
  • Performing the same operation multiple times

Consider this example:
Database query       50 ms
Application logic   600 ms
Serialization        50 ms
---------------------------
Total               700 ms


Optimizing the SQL query from 50 ms to 30 ms will barely change the overall response time.

The application logic is where the majority of the time is being spent.

This is where profiling becomes useful.

Instead of guessing which method is slow, use profiling and tracing to identify the actual hot path.

How to Troubleshoot a Slow ASP.NET Core API?

When an endpoint becomes slow, I recommend following a structured process rather than changing multiple things at once.

Step 1: Establish the baseline

Record:

  • Average latency
  • P95 latency
  • P99 latency
  • Requests/sec
  • Error rate

Step 2: Break Down the Request
Measure:

  • API processing
  • Database
  • External APIs
  • Serialization
  • Network

Step 3: Check Database Activity
Look for:

  • Slow queries
  • Missing indexes
  • Large result sets
  • N+1 queries
  • Repeated database calls

Step 4: Check Asynchronous Code
Search the codebase for:
.Result
.Wait()
Blocking locks
Synchronous I/O


Step 5: Trace External Dependencies
Find out whether the API is waiting on:

Payment services
Authentication providers
CRMs
Third-party APIs
Internal microservices


Step 6: Inspect Response Size

Check whether the endpoint is returning more data than the client actually needs.

Step 7: Profile Application Code
If the database and dependencies look healthy, profile the application itself.

Step 8: Fix One Bottleneck

Don't make five performance changes at once.

Change one thing, measure the result, and continue from there.

A Simple Performance Investigation Example

Suppose an endpoint has this profile:
Request latency: 1,850 ms

SQL queries:       620 ms
External API:      900 ms
Application code:  180 ms
Serialization:      80 ms
Other:               70 ms


It would be tempting to start optimizing the C# application code.

But the measurements tell us something different.

The two largest contributors are:
External API     900 ms
Database         620 ms

Those are the first areas worth investigating.

Now imagine the database profile reveals:
1 query
+
100 related queries
=
101 database calls


Fixing the N+1 problem could have a much larger impact than micro-optimizing a LINQ expression elsewhere in the application.

This is why profiling beats guessing.

Final Takeaway

A slow ASP.NET Core API does not necessarily mean you need a larger server or more CPU.

The bottleneck may be:

  • SQL queries
  • EF Core N+1 queries
  • Blocking code
  • Thread pool starvation
  • External APIs
  • Large JSON responses
  • Poor caching
  • Connection management
  • Middleware and logging
  • Application logic

The most effective starting point is simple:
Measure where the request spends its time before changing the implementation.
Once you know whether the latency is coming from the database, application code, external dependencies, serialization, or infrastructure, the optimization path becomes much clearer.
For production ASP.NET Core applications, especially APIs supporting SaaS and enterprise workloads, monitoring P95/P99 latency and tracing dependencies can help catch performance regressions before they become user-facing problems.

Summary

CPU and memory use are not necessarily the only indicators of ASP.NET Core API performance issues. Latency can be caused by a variety of factors, including slow database queries, N+1 queries, blocking activities, external dependencies, big answers, cache choices, connection management, middleware, logging, and application logic. A useful method of troubleshooting performance without depending on conjecture is to measure the request, trace its dependencies, identify the real bottleneck, and measure again after each modification.

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.



European ASP.NET Core 10.0 Hosting - HostForLIFE :: Evaluating .NET 11's Native AOT Performance

clock September 21, 2026 12:52 by author Peter

Historically, managed code execution in.NET applications has relied on the JIT compiler and the.NET runtime. Although there are other deployment options available, this strategy is effective for a variety of applications.

A distinct strategy is used in native AOT (Ahead-of-Time) compilation. The program is compiled in advance into native code for the target platform rather than depending on JIT compilation when it launches. This can alter an application's memory utilization, deployment requirements, starting behavior, and compatibility with specific.NET technologies, among other aspects.

For applications like command-line tools, tiny services, containerized apps, and workloads where startup time is crucial, native AOT is especially intriguing.

But not all applications immediately benefit from native AOT's speed.

The right way to evaluate it is to build the same application using both deployment models and measure the things that matter for your workload.

This article explains how Native AOT works, how to enable it, what to benchmark, and what limitations developers should consider.

What Is Native AOT?

With a traditional .NET deployment, the application contains managed assemblies.

A simplified execution flow looks like this:
C# Source
    |
    v
IL Assemblies
    |
    v
.NET Runtime
    |
    v
JIT
    |
    v
Native Machine Code


Native AOT changes the model:
C# Source
    |
    v
IL Assemblies
    |
    v
Native AOT Compiler
    |
    v
Native Executable
    |
    v
Operating System


Much of the compilation work happens before the application is deployed. The resulting executable is native code for a specific target platform and architecture.

This means Native AOT is not simply a switch that makes a normal .NET application run faster.

It changes the deployment and execution model.

Why Developers Use Native AOT

Native AOT can be useful when an application needs characteristics such as:

  • Fast startup
  • Smaller runtime dependency requirements
  • Self-contained native deployment
  • Reduced JIT work at startup
  • Predictable deployment artifacts
  • Useful behavior for short-lived processes

Consider a command-line utility:
mytool --input data.json

If the tool runs for a few hundred milliseconds, startup overhead can represent a meaningful part of its total execution time.
For a long-running web service that runs continuously for days, startup may matter much less.

This is why workload type is important.

Native AOT vs JIT Deployment

The two approaches have different characteristics.

Area

Traditional JIT

Native AOT

Compilation

At runtime

Before deployment

Startup work

Includes JIT work

Less JIT work

Target-specific binary

Runtime generates code for target

Native binary built for target

Dynamic code

Broad support

More restricted

Reflection

Broad support

Requires additional consideration

Deployment

Managed assemblies and runtime

Native executable

Build process

Generally simpler

More involved

Best fit

General-purpose applications

Suitable workloads with AOT-compatible dependencies

Native AOT is therefore a deployment choice, not simply a performance setting.

Enabling Native AOT
For a suitable .NET application, Native AOT can be enabled in the project file.

For example:
<PropertyGroup>
  <OutputType>Exe</OutputType>
  <TargetFramework>net11.0</TargetFramework>
  <PublishAot>true</PublishAot>
</PropertyGroup>

You can then publish the application for a target runtime.

For example:
dotnet publish -c Release -r linux-x64

The runtime identifier should match the environment where the application will run.

For an Arm64 Linux environment, the target would need to be the appropriate Arm64 runtime identifier instead.

The important point is that Native AOT produces a native binary for a specific target.

A Simple Console Application


Start with a small application:
using System.Diagnostics;

Console.WriteLine("Application started.");

var stopwatch = Stopwatch.StartNew();

long total = 0;

for (int i = 0; i < 10_000_000; i++)
{
    total += i;
}

stopwatch.Stop();

Console.WriteLine($"Total: {total}");
Console.WriteLine($"Work time: {stopwatch.ElapsedMilliseconds} ms");


This is useful for experimenting, but it is not enough to conclude that Native AOT is faster.

The test only measures one small piece of work.

A useful evaluation needs to measure startup, execution, memory, and deployment characteristics separately.
Startup Time

Startup is one of the areas where Native AOT can be particularly relevant.

A traditional application may perform several runtime initialization tasks before reaching the main application code.

A Native AOT application has already been compiled to native code.

To test startup, run the application as a separate process and measure the time from process launch to a known output or completion point.

Avoid using:

Stopwatch.StartNew();

inside the application to measure total startup.

That stopwatch starts after the process has already started.

Instead, use an external measurement mechanism or a suitable benchmarking tool.

For example, on Linux, a basic shell-level measurement can be used:

time ./myapp

This provides process-level timing information.

Repeat the test multiple times and compare the same application under both deployment models.
Cold Start vs Warm Start

Startup tests should distinguish between different conditions.
Cold Start

The application is started when relevant code and files are not already benefiting from the operating system's caches.
Warm Start

The application is launched after the operating system has already cached relevant files.

These scenarios can produce different results.

For applications such as serverless functions and short-lived CLI tools, cold-start behavior may be particularly important.

For long-running services, startup may have a much smaller impact on overall performance.
Measuring Memory Usage

Memory usage is another important measurement.

Do not simply compare executable file sizes and assume that the smaller file consumes less memory at runtime.

Measure the running process.

A practical test should record:

    Initial memory

    Steady-state memory

    Peak memory

    Allocation behavior

    Garbage collection activity

For a long-running service, steady-state behavior may matter more than startup memory.

For a short-lived process, startup and peak memory can be more relevant.
Throughput Testing

Native AOT should also be tested against the actual workload.

For example, imagine a service processing messages:

public static int Process(int value)
{
    return value * 2;
}

A benchmark might process many values:

public static long ProcessValues(int[] values)
{
    long total = 0;

    for (int i = 0; i < values.Length; i++)
    {
        total += Process(values[i]);
    }

    return total;
}

Now compare the same application deployed using normal JIT execution and Native AOT.

The important question is not whether one test is faster.

The question is whether the difference remains meaningful under the application's real workload.
Native AOT Does Not Mean No Runtime

Native AOT applications are native executables, but they still rely on runtime support and libraries.

The important distinction is that application code is compiled ahead of time instead of being JIT-compiled in the normal way at runtime.

Native AOT also has a different set of supported features and restrictions.

This is particularly important for applications that depend heavily on runtime code generation or unrestricted reflection.
Reflection Requires Attention

Reflection is widely used in .NET applications.

For example:

Type type = typeof(Customer);

PropertyInfo[] properties =
    type.GetProperties();

Applications that dynamically discover types, members, or assemblies may require additional work when using Native AOT.

Native AOT relies heavily on knowing what code is required at build time.

If the compiler cannot determine that a type or member is required, the application may need appropriate metadata or code annotations.

This is one of the biggest areas to investigate before migrating an existing application.
Dynamic Code Can Be a Problem

Some libraries generate code dynamically at runtime.

Examples can include:

  • Runtime proxies
  • Dynamic serializers
  • Expression compilation
  • Runtime assembly generation
  • Certain reflection-heavy frameworks

An application may work correctly under the normal JIT deployment model and then require changes for Native AOT.

This is why compatibility testing should happen before performance testing.

There is no value in benchmarking an AOT build that cannot correctly execute the application's actual features.

A Practical Testing Process

A reliable Native AOT evaluation can follow these steps.

Step 1: Choose a Representative Application
Do not start with a toy application unless you are learning how the technology works.
For a real migration decision, use a representative service or tool.

Step 2: Build the Normal Version
Publish the application using the normal deployment model.

For example:
dotnet publish -c Release

Step 3: Enable Native AOT
Add the appropriate project configuration:
<PropertyGroup>
  <PublishAot>true</PublishAot>
</PropertyGroup>

Then publish for the target platform:
dotnet publish -c Release -r linux-x64

Step 4: Verify Functionality
Before measuring performance, verify:

  • Application startup
  • API behavior
  • Serialization
  • Configuration
  • Logging
  • Database access
  • Authentication
  • Error handling
  • External integrations

Step 5: Measure Startup
Run the same executable repeatedly and record startup behavior.

Step 6: Measure Memory
Measure both startup and steady-state memory.

Step 7: Measure Throughput
Run realistic workloads against both versions.

Step 8: Compare Results

se the same hardware, operating system, configuration, and input data.

Benchmarking Application Code
For CPU-focused comparisons, BenchmarkDotNet can be useful.

For example:
using BenchmarkDotNet.Attributes;

public class ProcessingBenchmark
{
    private readonly int[] values = new int[100_000];

    [Benchmark]
    public long Process()
    {
        long total = 0;

        for (int i = 0; i < values.Length; i++)
        {
            total += values[i] * 2;
        }

        return total;
    }
}

This measures application code rather than complete process startup.
That distinction matters.

Use process-level measurements for startup and application-level benchmarks for CPU operations.
Do not use one benchmark to answer every performance question.

What Should You Measure?

A useful comparison might look like this:

Metric

Why it matters

Startup time

Important for short-lived processes

First request latency

Useful for services

Steady-state latency

Shows normal runtime behavior

Throughput

Shows processing capacity

Peak memory

Important for constrained environments

Steady-state memory

Useful for long-running services

CPU usage

Helps identify processing differences

Binary size

Important for deployment and distribution

Build time

AOT can increase build complexity

Compatibility

Required before performance comparisons

This gives a much more complete picture than simply comparing execution time.

Common Mistakes
Comparing Debug Builds
Always use the appropriate Release configuration when performing performance testing.
dotnet publish -c Release

Testing Different Hardware
If the JIT version runs on one server and the AOT version runs on another, the comparison is difficult to interpret.

Use the same environment where possible.

Measuring Only Startup

Native AOT can change startup characteristics, but that does not tell you how the application behaves after it has been running for hours.

Measuring Only CPU Time

A service may have excellent CPU performance but still have a memory or I/O bottleneck.

Ignoring Compatibility
An application that cannot use an important dependency under AOT is not a successful AOT candidate regardless of benchmark results.

Best Practices
Keep a Baseline

Before changing the deployment model, record the existing application's performance.

Use Production-Like Workloads

If production processes JSON requests, test JSON requests.

If production processes files, test representative files.

Test on the Target Architecture

An AOT binary is built for a specific target environment.

Test it where it will actually run.

Separate Startup and Steady-State Tests
Do not mix the two measurements.

Test Multiple Runs
One run can be affected by system activity and caching.

Use repeated measurements and look for consistent results.

Check Dependencies Early
Review the libraries used by the application before committing to an AOT migration.

Monitor Real Applications
If the application is deployed, continue measuring real behavior after the change.

Advantages of Native AOT

Native AOT can provide several practical benefits for suitable applications:

  • Less JIT work at startup
  • Native executable deployment
  • Potentially useful startup characteristics
  • Reduced dependency on runtime compilation
  • Useful fit for certain short-lived workloads
  • Can simplify deployment for some environments

The actual benefit depends on the application.

Disadvantages and Trade-Offs
Native AOT also introduces trade-offs:

  • Some .NET features require additional consideration
  • Reflection-heavy applications may need changes
  • Some libraries may not be AOT-compatible
  • Builds can become more involved
  • Native binaries are target-specific
  • Debugging and diagnostics can differ from a normal managed deployment
  • Dynamic code generation has restrictions

These are not reasons to avoid Native AOT.

They are reasons to evaluate it against the actual application.

When Native AOT Is Worth Testing
Native AOT is particularly worth evaluating for:

  • Command-line tools
  • Short-lived workers
  • Small services
  • Container workloads
  • Applications where startup matters
  • Resource-constrained environments

It may be less compelling when:

  • The application runs continuously for long periods.
  • Startup time is insignificant.
  • The application relies heavily on dynamic runtime behavior.
  • Dependencies are not compatible with AOT.
  • The main bottleneck is database or network I/O.

Again, these are starting points rather than universal rules.

Troubleshooting Native AOT Builds

If publishing fails, start by examining the build output.

Common areas to investigate include:

Reflection

Look for code that discovers types or members dynamically.

Dynamic Code Generation
Check whether a dependency requires runtime-generated code.

Serialization

Verify that the serializer and configuration are compatible with the AOT deployment model.

Dependencies

A library used by the application may have AOT limitations.

Platform Target
Make sure the runtime identifier matches the actual deployment environment.

For example:
dotnet publish -c Release -r linux-x64

should not be used to produce the binary intended for a different architecture.

A Simple Evaluation Matrix
Before adopting Native AOT, document the results.

Test

JIT Deployment

Native AOT

Observation

Startup

Measure

Measure

Compare repeated runs

First request

Measure

Measure

Use same request

Throughput

Measure

Measure

Same workload

Peak memory

Measure

Measure

Same environment

Steady-state memory

Measure

Measure

Long-running test

CPU usage

Measure

Measure

Same workload

Build time

Measure

Measure

Include CI build

Compatibility

Verify

Verify

All required features

The purpose of this table is not to produce a single winner.

It gives the team enough information to decide whether the deployment model fits the application's requirements.

Summary
Native AOT changes how a .NET application is compiled and deployed. Instead of depending on normal JIT compilation for application code at runtime, the application is compiled ahead of time into a native executable for a specific target environment.

The most useful way to evaluate Native AOT is through measurement. Test startup time, first-request behavior, steady-state performance, memory usage, CPU usage, binary size, build time, and application compatibility.

Native AOT can be a good fit for applications where startup and deployment characteristics matter, but it is not a universal performance switch. Applications that rely heavily on reflection, dynamic code generation, or incompatible dependencies may require additional changes.

For developers considering Native AOT, the safest approach is to establish a baseline, build an AOT version, verify that all required functionality still works, and then compare both versions using the same hardware and realistic workloads. That gives you evidence about whether Native AOT is useful for your application instead of relying on a generic performance claim.

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.



European ASP.NET Core 10.0 Hosting - HostForLIFE :: Identifying ASP.NET Thread Pool Starvation A Runtime-Level Guide to Core APIs

clock September 14, 2026 13:39 by author Peter

Even with a healthy CPU and memory utilization, an ASP.NET Core API may nevertheless see reduced performance, request timeouts, and rising latency. ThreadPool starvation is one explanation. When ThreadPool worker threads are busy for prolonged periods of time typically due to blocking waits or synchronous I/O—this is known as ThreadPool hunger. Incoming work may have to wait longer before execution starts as available workers become increasingly limited. Understanding this behavior for high-concurrency APIs necessitates looking beyond infrastructure data and analyzing the actions of the.NET runtime.

Understanding the ThreadPool
ASP.NET Core uses the .NET ThreadPool to execute application work, including request processing and other asynchronous operations.

Consider this code:
public IActionResult GetData()
{
    var result = service.GetDataAsync().Result;
    return Ok(result);
}

Although GetDataAsync() returns a Task, calling .Result blocks the current worker thread until the operation completes.

The same problem can occur with:
service.GetDataAsync().Wait();

Under low concurrency, these calls may appear harmless. Under sustained load, however, many requests can occupy ThreadPool workers while waiting for I/O.

The result can be a feedback loop:
Incoming requests → blocked workers → queued work → increasing latency → more concurrent requests → additional blocked workers
This is one reason ThreadPool starvation can become difficult to identify from CPU utilization alone.

Why CPU Utilization Can Be Misleading

A common troubleshooting assumption is:
High latency + low CPU = infrastructure problem.

That isn't necessarily true.
If worker threads are waiting on synchronous operations, the CPU may remain relatively underutilized while application work continues to accumulate.

Useful signals to examine include:

  • ThreadPool thread count
  • ThreadPool queue length
  • Work-item throughput
  • Request latency
  • Request queue duration
  • Exception and timeout rates
  • Garbage collection activity
  • External dependency latency

The important point is to correlate runtime metrics with application-level metrics rather than examining CPU or memory in isolation.

A Common Sync-over-Async Pattern

Consider an API endpoint that performs an asynchronous database operation:
public async Task<IActionResult> GetCustomer(int id)
{
    var customer = await repository.GetCustomerAsync(id);
    return Ok(customer);
}


The request thread isn't synchronously waiting for the I/O operation.

Compare that with:
public IActionResult GetCustomer(int id)
{
    var customer = repository.GetCustomerAsync(id).Result;
    return Ok(customer);
}

The second implementation introduces synchronous blocking.

The solution isn't simply to change the controller signature to async.

The asynchronous behavior needs to continue through the call chain:
Controller
    ↓
Service
    ↓
Repository
    ↓
Database/HTTP Client
    ↓
Async I/O

If a dependency in the middle of this chain performs synchronous blocking, the application can still experience scalability problems.

Hidden Blocking in Dependencies
Application code isn't always the source of the problem.
Third-party SDKs, database providers, HTTP clients, file APIs, and legacy libraries can introduce blocking behavior.

For example:
var response = externalClient.GetDataAsync().Result;

Even if the external operation itself is I/O-bound, .Result keeps the current worker occupied while waiting.

When investigating production starvation, review the complete dependency chain rather than searching only for .Result and .Wait() in controller code.
Diagnosing the Runtime

1. Monitor with dotnet-counters
dotnet-counters can provide real-time runtime counters that help establish whether ThreadPool activity is changing as latency increases.

A typical investigation might involve:
dotnet-counters monitor --process-id <PID>

Correlate ThreadPool-related counters with:

  • Request rate
  • Response latency
  • Queue length
  • Error rate
  • Throughput

The goal isn't to look at one counter in isolation. You're looking for a pattern between workload, ThreadPool behavior, and application performance.

2. Capture Runtime Traces

When counters indicate abnormal behavior but don't explain why it is happening, runtime tracing can provide deeper visibility.

dotnet-trace collect --process-id <PID>
A trace can help engineers investigate thread activity, blocking behavior, runtime events, and periods of increased waiting.

This is particularly useful when the problem appears intermittently under production-like concurrency.

3. Profile Before Production
Performance profiling in development or staging environments can help identify blocking operations before they become production incidents.

Load testing is especially valuable when combined with profiling because a blocking operation that looks insignificant with a few concurrent requests can behave very differently at hundreds or thousands of concurrent requests.

ThreadPool Configuration Is Not the First Fix

Increasing the minimum number of ThreadPool threads may appear to improve a workload temporarily.
However, configuration changes should not be used as a substitute for removing unnecessary blocking.
If application code consistently occupies worker threads with synchronous waits, increasing available workers can simply delay the point at which the bottleneck becomes visible.

The first question should therefore be:

Why are ThreadPool workers blocked?
Only after understanding the workload should ThreadPool configuration be considered.

Designing the Application to Avoid Starvation

A scalable ASP.ET Core API should minimize unnecessary blocking throughout its execution path.

Key practices include:
Use asynchronous APIs for I/O

Prefer:
var data = await repository.GetDataAsync();

over:
var data = repository.GetDataAsync().Result;

Keep async operations asynchronous
Avoid introducing synchronous waits between asynchronous layers.

Review external integrations

HTTP calls, database operations, cloud services, and SDKs should be evaluated for proper asynchronous support.

Move long-running work away from request paths

If an operation doesn't need to execute during the HTTP request, consider background processing or messaging rather than keeping the request thread occupied.

Measure under realistic concurrency
A performance test should reproduce realistic concurrency, dependency latency, payload sizes, and traffic patterns rather than testing only average request volume.

ThreadPool Starvation vs. Infrastructure Scaling

Adding more application instances can increase overall capacity, but it doesn't necessarily eliminate the underlying blocking behavior.
For example, if every instance contains the same synchronous bottleneck, horizontal scaling may simply distribute the same inefficient execution pattern across more servers.
This is why application-level optimization should come before assuming that more compute capacity is the solution.

A Practical Investigation Workflow

When an ASP.NET Core API starts timing out under load, a useful investigation sequence is:

  • Confirm the symptom — Check latency, throughput, timeouts, and request queues.
  • Inspect runtime metrics — Look at ThreadPool activity and queued work.
  • Search for blocking operations — Review .Result, .Wait(), synchronous I/O, and blocking integrations.
  • Analyze dependencies — Check database providers, HTTP clients, SDKs, and third-party libraries.
  • Capture runtime traces — Use tracing when counters aren't sufficient.
  • Reproduce under load — Validate the suspected bottleneck with realistic concurrency.
  • Fix the root cause — Remove unnecessary blocking and maintain asynchronous execution through the dependency chain.
  • Monitor after deployment — Confirm that latency, throughput, and runtime behavior improve in production.

Final Thoughts
ThreadPool starvation is a runtime-level scalability problem that can remain hidden behind apparently healthy infrastructure metrics.
For ASP.NET Core applications handling concurrent requests, the important question isn't simply whether the server has enough CPU or memory. It's whether the application can efficiently use its available worker threads while waiting for I/O and processing incoming work. Understanding sync-over-async behavior, dependency execution, ThreadPool metrics, runtime traces, and realistic load patterns gives developers a much stronger foundation for diagnosing these incidents.

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.



European ASP.NET Core 10.0 Hosting - HostForLIFE :: Why Data in ASP.NET Applications May Be Silently Corrupted by Multi-Tab Browsing

clock September 10, 2026 11:23 by author Peter

When working with Session, any ASP.NET developer may initially consider it to be a secure place to save the current screen. When the same user accesses numerous tabs of the program, the assumption falls down. However, it can function in a straightforward, single-tab process. This article describes an actual multi-tab session-state problem, explains why it might quietly generate wrong data, and shows how to avoid it by checking business rules at the write point and maintaining request-specific context with the request.

A Realistic Scenario
Consider an HR or payroll-style application where a logged-in user can view different employee records in multiple browser tabs.

For example:

  • Tab 1 has Employee A open, who qualifies for one type of transaction.
  • Tab 2 has Employee B open, who qualifies for a different type.
  • Both tabs belong to the same logged-in user.

The important detail is that both tabs use the same browser session cookie.

In ASP.NET, Session state is associated with the user's session, not with an individual browser tab. Therefore, both tabs can access and modify the same Session state.

How the Bug Actually Happens
Suppose the application stores employee-specific information in Session when a screen loads:
Session["CurrentEmployeeId"] = employeeId;
Session["EligibleTransactionType"] = eligibilityType;


Now consider the following sequence.

Step 1: Open Employee A

The user opens Employee A in Tab 1.

The application stores:
CurrentEmployeeId = Employee A
EligibleTransactionType = Type A

Step 2: Open Employee B
The user opens Employee B in Tab 2.

The application updates the same Session:
CurrentEmployeeId = Employee B
EligibleTransactionType = Type B

The Session values now represent Employee B.

However, Tab 1 is still displaying Employee A.

Step 3: Save Employee A

The user switches back to Tab 1 and clicks Save.

If the save operation retrieves the eligibility from Session:
var eligibilityType =
    Session["EligibleTransactionType"].ToString();

the value may now represent Employee B rather than Employee A.
The application can therefore apply Employee B's eligibility rule while processing Employee A.
Nothing necessarily crashes. There may be no exception and no obvious UI error.

The application is simply using shared Session state for information that actually belongs to a specific request or record.

Why This Bug Is Dangerous

This type of bug is particularly difficult to identify because the application can behave normally from a technical perspective while producing incorrect business results.

Single-Tab Testing May Not Find It
If QA tests the workflow using only one browser tab, the Session value generally remains associated with the record being viewed.

The problem appears when multiple tabs modify the same Session state.

The Result Can Look Valid

An incorrect transaction type or business rule may still be a valid value.

That makes the problem more difficult to detect than an exception or validation error.

The Problem Can Appear Random

The result depends on the order in which the user opens records and performs actions.

For example:
Tab 1 → Employee A
Tab 2 → Employee B
Tab 1 → Save


can produce a different result from:
Tab 1 → Employee A
Tab 2 → Employee B
Tab 2 → Save

The behavior may therefore appear inconsistent even though the application is following the same code path.

Production Data Can Be Affected

If the incorrect Session value influences a database write, the problem is no longer just a UI issue. It can result in incorrect business data being persisted.

The Underlying Misconception

The core problem is usually the mental model developers have about Session state.

It is easy to think of Session as:
Session = state for the current screen

But the more accurate model is:
Session = state associated with the user's session

Multiple tabs belonging to the same browser session can therefore access the same Session state.

There is no automatic tab-level isolation for ASP.NET Session state.

This distinction becomes important whenever Session contains information that changes according to the record currently displayed.

The Fragile Approach

Consider this pattern:
if (Session["EligibleTransactionType"].ToString() == requestedType)
{
    Save();
}

The problem is not the if statement itself.
The problem is the source of EligibleTransactionType.

The code assumes that the Session value still belongs to the employee being saved. In a multi-tab workflow, that assumption may be false.

A Safer Approach
The server should identify the record being saved and retrieve the authoritative business information for that record.

For example:
var currentEligibility =
    _employeeService.GetEligibility(employeeIdFromForm);

if (currentEligibility == requestedType)
{
    Save();
}


Now the eligibility is obtained using the actual employee ID associated with the request.

The important difference is:
Fragile:
Session → Eligibility → Save

Safer:
Request Employee ID → Database/Service → Eligibility → Save


The second approach does not depend on whichever employee was most recently stored in Session.

Keep Record Context With the Request

Another important practice is to carry the identity of the record being edited as part of the request.

For example, the employee ID can be supplied through a route value:
/employees/edit/101

or through a form field:
<input type="hidden" name="employeeId" value="101" />

The exact mechanism can vary depending on the application architecture.
The important principle is that each request should contain enough information to identify the record it is operating on.

Instead of relying on:
Session["CurrentEmployeeId"]

the server can use the employee ID associated with the current request:
var employeeId = employeeIdFromRequest;

The request is then self-contained with respect to the record being processed.

Treat Session as Convenience State

Session can still be useful.
For example, it can be appropriate for information such as:

  • Temporary user preferences.
  • UI-related state.
  • Non-critical convenience information.
  • Cached values that can safely become stale.

However, Session should not be treated as the authoritative source for business rules that determine whether a database write is allowed.

A useful distinction is:
Session:
Convenience / temporary state

Database or authoritative service:
Business-critical state


If a value determines whether a transaction can be performed, the server should validate that value against the authoritative source before committing the change.

Validate Again Before Writing

A final server-side validation immediately before a write provides an additional safety boundary.

For example:
var employee = _employeeService.GetEmployee(employeeId);

var currentEligibility =
    _employeeService.GetEligibility(employee.Id);

if (currentEligibility != requestedType)
{
    throw new InvalidOperationException(
        "The requested transaction type is no longer valid.");
}

SaveTransaction(employee.Id, requestedType);


The exact exception-handling strategy will depend on the application's architecture, but the important principle is that the server should not blindly trust client-side or Session-derived business context.

The final write should be based on current, authoritative information.

A Multi-Tab Test Case

This bug should be explicitly included in testing for applications that use Session for record-specific state.

A simple test scenario is:
Test Setup

Open the application using one authenticated browser session.

Test Steps

  • Open Employee A in Tab 1.
  • Confirm Employee A's information.
  • Open Employee B in Tab 2.
  • Confirm Employee B's information.
  • Return to Tab 1.
  • Perform the save operation for Employee A.
  • Verify that the operation uses Employee A's business rules.
  • Check the resulting database record.

Then reverse the order:

  • Open Employee A in Tab 1.
  • Open Employee B in Tab 2.
  • Save Employee B.
  • Return to Tab 1.
  • Save Employee A.
  • Verify both records independently.

This test is valuable because it intentionally changes shared Session state between requests.

Common Warning Signs
An application is worth reviewing if it contains patterns such as:
Session["CurrentId"]
Session["CurrentEmployee"]
Session["SelectedRecord"]
Session["TransactionType"]
Session["Eligibility"]

especially when these values are later used during database updates.

The key question is:
Does this Session value describe the user, or does it describe a particular record or request?
User-level state and record-specific state should not automatically be treated the same way.

A Safer Design Pattern

For multi-screen workflows, a safer request flow looks like this:
Browser Tab
    |
    | Employee ID
    v
Controller / Endpoint
    |
    v
Business Service
    |
    | Retrieve current business rules
    v
Database / Authoritative Source
    |
    v
Validate
    |
    v
Save

Session may still exist alongside this flow, but the critical business decision should not depend solely on mutable Session state.

Practical Guidelines
When working with ASP.NET Session state:

  • Do not assume Session is tab-specific.
  • Avoid storing record-specific context in Session when multiple tabs can edit different records.
  • Carry the record ID with each request.
  • Retrieve business-critical information using that record ID.
  • Treat Session as convenience or temporary state rather than the source of truth.
  • Perform server-side validation before database writes.
  • Include multi-tab scenarios in QA and integration testing.
  • Review existing applications for Session values that control database updates.

Conclusion
A user's session, not a specific browser tab, is linked to the ASP.NET Session state. When record-specific data is saved in Session and then utilized in a subsequent request, this distinction may result in minor problems. The safest course of action is to identify the actual record being processed, maintain request-specific context with the request, and re-validate business-critical information against the authoritative source prior to writing data.

The fundamental idea is straightforward:
The business rule that applies to a database write should not be determined by the mutable session state.

An whole class of hard-to-reproduce production issues is eliminated and the program becomes considerably more robust to multi-tab use when the save procedure is designed around the actual record being processed.

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.



European ASP.NET Core 10.0 Hosting - HostForLIFE :: Real C# Benchmarks for the .NET 10 Performance Battle: Span<T> vs. Array vs. List<T>

clock September 7, 2026 12:47 by author Peter

Selecting the appropriate data structure is frequently the key to C# performance improvement. Sequences of values can be represented by T[], List, and Span, although they differ significantly in terms of allocation, memory access, slicing, and iteration.

This comparison is made much more intriguing with.NET 10. JIT optimization, loop cloning, devirtualization, stack allocation, and Span handling are all improved by the runtime. Microsoft particularly points out that.NET 10 extends significant JIT improvements to span-based loops and that more code is being developed around spans.

In order to compare arrays, lists, and spans, this article constructs a useful BenchmarkDotNet test and discusses when each strategy makes sense.

Why Compare Span, Array, and List?
At first glance, these types appear interchangeable:
int[] array = new int[1000];

List<int> list = new List<int>(1000);

Span<int> span = array;


All three allow indexed access:
value = array[index];
value = list[index];
value = span[index];


But their underlying behavior is different.
An array is a fixed-size managed object with contiguous elements.
List<T> is a dynamically sized collection backed internally by an array.

Span<T> is a lightweight ref struct representing a contiguous region of memory. It can provide a view over an array without creating another collection.

That distinction becomes important in hot loops, parsers, serializers, networking code, image processing, and other performance-sensitive workloads.

What Changed With .NET 10?

.NET 10 is an LTS release and introduces several runtime performance improvements, including better JIT code generation, devirtualization, stack allocation, and loop optimizations.

One particularly relevant improvement is that JIT loop cloning can now apply more effectively to span-based code.

Consider:
static int Sum(Span<int> values)
{
    int total = 0;

    for (int i = 0; i < values.Length; i++)
    {
        total += values[i];
    }

    return total;
}


The runtime can optimize this kind of loop aggressively.

Microsoft's .NET 10 performance work specifically demonstrates improvements around span loops and bounds-check optimization.

C# 14 also introduces first-class span conversions, making interactions between arrays, Span<T>, and ReadOnlySpan<T> more natural.

Benchmark Setup

To make the comparison meaningful, use BenchmarkDotNet rather than relying on Stopwatch.

Create a console application:
dotnet new console -n SpanPerformance
cd SpanPerformance
dotnet add package BenchmarkDotNet

Then use the following benchmark:
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

namespace SpanPerformance;

[MemoryDiagnoser]
public class CollectionBenchmarks
{
    private int[] _array = null!;
    private List<int> _list = null!;

    [GlobalSetup]
    public void Setup()
    {
        _array = Enumerable.Range(0, 10_000).ToArray();
        _list = _array.ToList();
    }

    [Benchmark]
    public int ArrayLoop()
    {
        int sum = 0;

        for (int i = 0; i < _array.Length; i++)
        {
            sum += _array[i];
        }

        return sum;
    }

    [Benchmark]
    public int ListLoop()
    {
        int sum = 0;

        for (int i = 0; i < _list.Count; i++)
        {
            sum += _list[i];
        }

        return sum;
    }

    [Benchmark]
    public int SpanLoop()
    {
        int sum = 0;

        Span<int> span = _array;

        for (int i = 0; i < span.Length; i++)
        {
            sum += span[i];
        }

        return sum;
    }
}


Run the benchmark in Release mode:
dotnet run -c Release

BenchmarkDotNet executes multiple iterations and reports statistics such as mean execution time, error, standard deviation, and memory allocation.

Do not treat a single Stopwatch measurement as a reliable benchmark. JIT compilation, CPU frequency changes, garbage collection, OS scheduling, and other processes can distort short measurements.

Benchmark 1: Sequential Iteration
The first test asks a simple question:

How efficiently can each type be traversed?
The three implementations are conceptually equivalent:
for (int i = 0; i < array.Length; i++)
{
    sum += array[i];
}

for (int i = 0; i < list.Count; i++)
{
    sum += list[i];
}

Span<int> span = array;

for (int i = 0; i < span.Length; i++)
{
    sum += span[i];
}


For this workload, you should generally expect array and span performance to be very close, particularly when the span is simply a view over the same array.

The important point is that Span<T> isn't automatically a faster replacement for every array.

If the underlying data is already an array and your loop is straightforward, the JIT can optimize array access extremely well.

.NET 10 further improves these optimizations, including bounds-check handling and loop cloning for spans.

Benchmark 2: Slicing Without Allocation

This is where Span<T> becomes more interesting.
Suppose you only need elements 2,000 through 5,000 from an array.

A traditional approach might create a new array:
int[] subset = new int[3000];

Array.Copy(
    _array,
    2000,
    subset,
    0,
    3000);


That creates additional storage and copies the data.

With a span:
Span<int> subset = _array.AsSpan(2000, 3000);

No new element array is created.

The span simply represents a view over the existing memory.

You can then process it:
int sum = 0;

foreach (int value in subset)
{
    sum += value;
}


This is one of the strongest practical use cases for Span<T>.

Instead of:
Original array
       ↓
Copy data
       ↓
New array
       ↓
Process

you can use:

Original array
       ↓
Span view
       ↓
Process


That can reduce both allocation and copying.

Benchmark 3: List and Span

An interesting optimization appears when the source data is a List<T>.

Modern .NET provides:
CollectionsMarshal.AsSpan(list)

This exposes a span over the list's backing storage. Microsoft documents this API as returning a Span<T> view over the list's data.

Example:
using System.Runtime.InteropServices;

Span<int> span = CollectionsMarshal.AsSpan(_list);

int sum = 0;

for (int i = 0; i < span.Length; i++)
{
    sum += span[i];
}


This can eliminate some abstraction overhead in performance-critical code.

However, there is an important safety rule.
You should not add or remove elements from the list while the span is being used. Microsoft explicitly documents this restriction for CollectionsMarshal.AsSpan.
Therefore, this technique should be reserved for controlled hot paths rather than becoming the default way you work with every List<T>.

Benchmark 4: Allocation Matters
Execution time isn't the only metric.

Use:
[MemoryDiagnoser]

in BenchmarkDotNet to track allocations.

For example, consider this:
int[] source = GetData();

int[] copy = source[1000..5000];

The range operation creates a new array.

Compare that with:
ReadOnlySpan<int> view = source.AsSpan(1000, 4000);

The second operation creates a span view rather than copying the elements into another array.

This distinction can matter enormously inside high-throughput applications.

For example,

  • JSON parsing
  • HTTP processing
  • binary protocols
  • serialization
  • image processing
  • log processing
  • file parsing
  • network buffers

These workloads can process millions of small pieces of data, making unnecessary allocations expensive.

Span Does Not Own Memory
One of the most important concepts developers need to understand is that Span<T> isn't an alternative collection in the same sense as List<T>.
It is better to think of it as a window over memory.

For example:
int[] numbers = { 10, 20, 30, 40, 50 };

Span<int> span = numbers.AsSpan(1, 3);

span[0] = 200;


The original array changes:
Console.WriteLine(numbers[1]);

Output:
200

The span didn't create an independent copy. It referenced the same memory.  This is one reason spans are powerful for high-performance APIs, but it is also why developers need to understand their lifetime and mutation behavior.

Array vs List vs Span
Here's the practical comparison.

Feature

Array

List

Span

Fixed size

Yes

No

View only

Dynamic resizing

No

Yes

No

Owns storage

Yes

Yes

No

Can avoid copying

Sometimes

Sometimes

Yes

Stack-friendly

No

No

Yes

Works over arrays

Natively

N/A

Yes

Works over list storage

N/A

Natively

Via CollectionsMarshal

Allocation-free view

No

No

Yes

Best use

Fixed collections

Dynamic collections

Hot-path memory access

The key takeaway is that these aren't competing abstractions in every scenario. They solve different problems.

When Should You Use Array?

Use an array when:

  • The collection size is known.
  • You need simple indexed access.
  • You own the data.
  • The data needs to live on the managed heap.
  • You don't need dynamic resizing.

Example:
byte[] buffer = new byte[4096];

Arrays are also an excellent input for span-based APIs:
Process(buffer.AsSpan());

This allows an API to accept a span without forcing callers to copy their arrays.

When Should You Use List?

List<T> is still the right choice for many ordinary application scenarios.

Use it when:

  • Elements need to be added or removed.
  • Collection size changes dynamically.
  • Developer productivity matters more than micro-optimization.
  • You need normal collection APIs.
  • You don't have a demonstrated hot-path performance problem.

Example:
var users = new List<User>();

users.Add(user);
users.Remove(user);

Replacing every List<T> with a span would be a design mistake.

A span cannot replace the dynamic collection behavior that makes List<T> useful.

When Should You Use Span?

Span<T> becomes valuable when you need:

  • Low-allocation processing
  • Array slicing
  • Buffer manipulation
  • Parsing
  • Memory-efficient APIs
  • High-frequency loops
  • Stack-based temporary storage
  • Zero-copy processing

For example:
static int ParseFirstFourDigits(ReadOnlySpan<char> value)
{
    return int.Parse(value[..4]);
}


The method can operate directly on a portion of existing character data rather than requiring a new string.
This style is particularly valuable in parsers and high-throughput infrastructure.

A More Interesting .NET 10 Benchmark

To test .NET 10 specifically, BenchmarkDotNet can compare multiple runtimes.

For example:
dotnet run -c Release \
    --runtimes net9.0 net10.0


Your benchmark should then report separate results for each runtime.

This is more useful than simply asking which collection is faster because it demonstrates how the runtime itself affects the generated machine code.

Microsoft's own .NET 10 performance investigation uses BenchmarkDotNet and reports improvements across areas including JIT optimization, spans, allocations, and collection processing.

Don't Optimize Based on Assumptions
One of the biggest mistakes in C# performance engineering is assuming:
    Span is always faster.

That isn't true.

For a simple loop:
for (int i = 0; i < array.Length; i++)
{
    sum += array[i];
}


an array can already be highly optimized by the JIT.

.NET 10 has specifically improved array interface devirtualization and array iteration optimization.

The right question isn't:
    Which type is fastest?

It is:
    Which representation produces the least unnecessary work for this workload?

That distinction matters.

Practical Optimization Strategy

A good progression for production C# code is:

Step 1: Start with the simplest correct data structure
Use:
List<T>

when you need a dynamic collection.

Use:
T[]

when you need fixed-size storage.

Step 2: Profile
Identify the actual hot path.

Step 3: Benchmark
Use BenchmarkDotNet rather than intuition.

Step 4: Reduce allocations
Look for:

  • unnecessary arrays
  • string creation
  • LINQ allocations
  • temporary objects
  • repeated conversions
  • unnecessary copies

Step 5: Introduce Span
Use spans where they solve a demonstrated performance problem.

Step 6: Re-run the benchmark
Optimization isn't complete until the benchmark demonstrates an improvement.

Final Verdict
There is no universal winner in the Span<T> vs array vs List<T> performance battle.

For straightforward sequential processing, arrays and spans can be extremely close, because a span may simply be a view over the same underlying array.

For dynamic collections, List<T> remains the practical choice.

For slicing, parsing, buffer manipulation, and allocation-sensitive hot paths, Span<T> becomes particularly powerful because it can provide a view over existing memory without copying the underlying elements.

.NET 10 makes this area even more compelling. Its JIT improvements extend optimization opportunities for spans, arrays, collections, and generated machine code, while C# 14 adds more natural span conversions.

The best performance strategy is therefore not to replace every collection with Span<T>. Instead, benchmark the actual workload, understand where allocations and bounds checks occur, and use the lowest-overhead representation that fits the ownership and lifetime requirements of the data.

Summary

For developers working on parsers, serializers, networking, high-throughput APIs, or other performance-critical .NET applications, that distinction can be far more important than the raw benchmark number from a single micro-test.

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.



About HostForLIFE.eu

HostForLIFE.eu is European Windows Hosting Provider which focuses on Windows Platform only. We deliver on-demand hosting solutions including Shared hosting, Reseller Hosting, Cloud Hosting, Dedicated Servers, and IT as a Service for companies of all sizes.

We have offered the latest Windows 2016 Hosting, ASP.NET Core 2.2.1 Hosting, ASP.NET MVC 6 Hosting and SQL 2017 Hosting.


Month List

Tag cloud

Sign in