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



European ASP.NET Core 10.0 Hosting - HostForLIFE :: Pipeline Pattern: A Basic Real-World Illustration

clock August 31, 2026 12:16 by author Peter

Combining several processes for a single request into a single, huge function may soon make the code difficult to comprehend and maintain. A straightforward solution to this issue is the Pipeline Pattern. It divides the process into smaller parts, each of which is in charge of a certain task.

An online payment API is a suitable real-world example.

Imagine a customer makes a payment. Before the payment can be completed, the application may need to go through several checks and actions:

  • Validate the payment request.
  • Check if the customer is allowed to make the payment.
  • Check the account balance.
  • Run fraud checks.
  • Process the payment.
  • Save the transaction details.
  • Send a notification to the customer.

Instead of putting all this logic into one controller or service method, we can create a pipeline and give each step its own responsibility.

Creating the Pipeline Step Interface
public interface IPipelineStep<T>
{
    int Order { get; }
    Task<T> ExecuteAsync(T context);
}

Each step implements the interface:
public class ValidatePaymentStep : IPipelineStep<PaymentContext>
{
    public int Order => 1;

    public Task<PaymentContext> ExecuteAsync(PaymentContext context)
    {
        Console.WriteLine("1. Validating payment...");

        if (string.IsNullOrWhiteSpace(context.CustomerId))
            throw new ArgumentException("CustomerId is required.");

        if (context.Amount <= 0)
            throw new ArgumentException("Payment amount must be greater than zero.");

        if (string.IsNullOrWhiteSpace(context.Currency))
            throw new ArgumentException("Currency is required.");

        return Task.FromResult(context);
    }
}


The pipeline executes these steps in sequence:
Payment Request
      |
      v
[Validate]
      |
      v
[Authorization]
      |
      v
[Fraud Check]
      |
      v
[Process Payment]
      |
      v
[Save Transaction]
      |
      v
[Send Notification]


This makes the code easier to understand, test, and maintain. It also makes it easier to add, remove, or change individual steps without affecting the rest of the process.

This approach works especially well in .NET because dependency injection makes it easy to register and connect different pipeline steps.

One of the main benefits is separation of concerns. Each step has a clear responsibility. For example, if we later need to add a customer credit-limit check, we can simply add a new pipeline step without changing the existing payment logic.

The Pipeline Pattern can be useful in many scenarios, such as payment processing, order processing, message processing, validation workflows, and API request processing.

Instead of creating one large service that handles everything, we can break the process into small and focused steps:
Request → Validate → Authorize → Process → Save → Notify


This keeps the code simple and makes the application easier to test, change, and maintain.

5 Essential Things to Consider in a Sequential Pipeline

Enforce Sequential Execution

Use foreach + await so the next step starts only after the current step completes.
foreach (var step in steps.OrderBy(x => x.Order))
{
    context = await step.ExecuteAsync(context);
}


Define Explicit Step Ordering
Don't depend only on DI registration order. Give each step an explicit Order so the business flow is clear and predictable.

Understand Step Dependencies

If Step 2 depends on the result of Step 1, the pipeline must remain sequential. Avoid Task.WhenAll for dependent operations.

Handle Failures and Side Effects

Decide what happens when a step fails. For database updates, payments, or messaging, consider retry, rollback/compensation, and idempotency.

Keep Steps Small and Testable

Each step should have one responsibility. This makes individual steps easier to test, replace, and extend without changing the entire pipeline.

Note

The most important design decision in a pipeline is not how many steps you have—it is understanding the dependency between those steps. When one step depends on the previous step, use explicit ordering and sequential await execution. Parallel execution should only be introduced when the operations are genuinely independent*.*

Conclusion
The Pipeline Pattern is a simple and clean way to break a complex business process into smaller steps. Each step focuses on one specific task, which makes the code easier to understand and maintain. When one step depends on the previous step, we can run the pipeline sequentially using proper ordering and await. This helps ensure that each step finishes before the next one starts.

As the application grows, we can easily add or change individual steps without affecting the entire workflow. We also need to think about error handling, dependencies, side effects, and testing when designing the pipeline.

With .NET and dependency injection, building this type of pipeline becomes straightforward, flexible, and easy to extend as business requirements change.
Happy Coding!

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 :: ZIP Password Support in.NET 11: Evaluating the Performance of Encrypted Archives

clock August 28, 2026 12:28 by author Peter

In.NET applications, ZIP files are ubiquitous. Backups, document exports, log bundles, file transfers, and application-generated reports are among their frequent uses. Because the built-in ZIP APIs did not offer native support for creating and reading encrypted entries, developers who required password-protected ZIP archives frequently relied on third-party libraries until.NET 11 added built-in support for encrypted ZIP archives, including ZipCrypto and WinZip AES encryption methods. Because ZipCrypto contains known cryptographic flaws, Microsoft advises using AES-256 for new archives.

This raises an intriguing performance query:

What does encryption add to ZIP creation and extraction, and how does that cost change as the archive becomes larger?

Instead of assuming that encryption is either "fast" or "slow," we can build a benchmark that measures archive creation time, extraction time, output size, memory usage, and CPU-related work.

What Changed in .NET 11?

.NET 11 adds password support directly to the ZIP APIs.

The new functionality supports:

  • ZipCrypto
  • WinZip AES-128
  • WinZip AES-192
  • WinZip AES-256

For new applications, AES-256 is the recommended choice. ZipCrypto should generally be used only when compatibility with older tools requires it.

For example, an encrypted entry can be created with AES-256:
using System.IO.Compression;

using var archive = ZipFile.Open(
    "documents.zip",
    ZipArchiveMode.Create);

archive.CreateEntry(
    "report.txt",
    "my-password",
    ZipEncryptionMethod.Aes256);


The exact API surface can vary across preview builds, so when evaluating .NET 11 preview features, developers should use the API available in the SDK they are testing.

Why Encryption Changes the Workload

A normal ZIP operation involves compression and archive management.

An encrypted ZIP adds cryptographic processing.

The simplified pipeline becomes:
Input files
    |
    v
Read file
    |
    v
Compress
    |
    v
Encrypt
    |
    v
Write ZIP


During extraction, the process is reversed:
Encrypted ZIP
    |
    v
Read archive
    |
    v
Decrypt
    |
    v
Decompress
    |
    v
Write files


That additional cryptographic work can affect CPU consumption and execution time.

The actual impact depends on several factors, including archive size, number of files, compression level, encryption method, storage speed, and the characteristics of the input data.

Creating an Encrypted ZIP Archive
.NET 11 also adds encryption options to the higher-level ZIP convenience APIs.

For example:
using System.IO.Compression;

ZipFile.CreateFromDirectory(
    sourceDirectory,
    "backup.zip",
    new ZipFileCreationOptions
    {
        Password = "my-password".AsMemory(),
        EncryptionMethod = ZipEncryptionMethod.Aes256,
        CompressionLevel = CompressionLevel.Optimal
    });


This approach is convenient when the requirement is simply:

  • Take a directory.
  • Compress its contents.
  • Encrypt the archive.
  • Save the result.

The password should not be hard-coded in a real application.

For example, an application might obtain it from a secure configuration or secret-management system:
var password = configuration["ArchivePassword"]
    ?? throw new InvalidOperationException(
        "Archive password is not configured.");


Secrets should not be committed to source control or written to application logs.

Extracting an Encrypted Archive

An encrypted archive can also be extracted using the convenience API:
ZipFile.ExtractToDirectory(
    "backup.zip",
    destinationDirectory,
    new ZipExtractionOptions
    {
        Password = "my-password".AsMemory(),
        OverwriteFiles = false
    });


For individual entries, the password can be supplied when opening the entry:
using ZipArchive archive =
    ZipFile.OpenRead("backup.zip");

foreach (var entry in archive.Entries)
{
    using Stream stream =
        entry.Open("my-password");

    // Process decrypted content.
}


Microsoft also exposes the encryption method through ZipArchiveEntry.EncryptionMethod, allowing applications to inspect how an entry was encrypted.

AES-256 vs ZipCrypto

The encryption algorithm matters both for security and interoperability.

Encryption MethodSecurityCompatibilityRecommended for New Archives

None

No encryption

Very high

No

ZipCrypto

Weak by modern standards

Broad

No

AES-128

Stronger

Depends on tool

Sometimes

AES-192

Strong

Depends on tool

Sometimes

AES-256

Strongest option provided

Depends on tool

Yes

Microsoft specifically recommends AES-256 for new archives and describes ZipCrypto as a legacy option that should be used only for backward compatibility. So a performance benchmark should not present ZipCrypto as the "better" option simply because it happens to require less work. Security requirements come first.

Designing a Fair Benchmark
A useful benchmark should compare the same input files across several configurations.

For example:
Test A
ZIP without encryption

Test B
ZIP + ZipCrypto

Test C
ZIP + AES-128

Test D
ZIP + AES-256


Use the same:

  • Files
  • Directory structure
  • Compression level
  • Storage location
  • Runtime
  • Operating system
  • Machine
  • Benchmark configuration

Only change the encryption setting.

This makes it easier to attribute differences to encryption rather than another variable.

Measuring Archive Creation

BenchmarkDotNet can be used to isolate archive creation.

For example:
using BenchmarkDotNet.Attributes;

[MemoryDiagnoser]
public class ZipBenchmark
{
    private const string SourceDirectory =
        "TestData";

    [Benchmark]
    public void CreateUnencrypted()
    {
        ZipFile.CreateFromDirectory(
            SourceDirectory,
            "plain.zip",
            CompressionLevel.Optimal,
            false);
    }

    [Benchmark]
    public void CreateEncrypted()
    {
        ZipFile.CreateFromDirectory(
            SourceDirectory,
            "encrypted.zip",
            new ZipFileCreationOptions
            {
                Password = "benchmark-password".AsMemory(),
                EncryptionMethod =
                    ZipEncryptionMethod.Aes256,
                CompressionLevel =
                    CompressionLevel.Optimal
            });
    }
}


The exact overloads available depend on the .NET 11 SDK build being tested. A benchmark should also avoid repeatedly using the same output file if the API or test setup causes previous results to influence the next run.

For example, clean up generated archives between iterations.

Measuring Extraction

Creation and extraction should be benchmarked separately.

They are different workloads.

A simple extraction benchmark can look like:
[Benchmark]
public void ExtractEncrypted()
{
    var destination = "Extracted";

    Directory.CreateDirectory(destination);

    ZipFile.ExtractToDirectory(
        "encrypted.zip",
        destination,
        new ZipExtractionOptions
        {
            Password =
                "benchmark-password".AsMemory(),
            OverwriteFiles = true
        });
}

Again, the benchmark needs cleanup between iterations so that the destination directory does not grow or interfere with subsequent measurements.

What Should Be Measured?
A useful benchmark should capture more than execution time.

MetricWhy It Matters

Creation time

Measures archive generation

Extraction time

Measures archive restoration

Archive size

Shows compression/encryption output size

Allocated memory

Shows managed allocation behavior

CPU usage

Shows processing cost

File count

Helps explain metadata overhead

Input size

Makes results reproducible

BenchmarkDotNet's memory diagnoser can provide useful allocation information:
[MemoryDiagnoser]
public class ZipBenchmark
{
// Benchmark methods
}


The actual measurements should be collected from the target environment rather than invented.

Input Data Matters More Than It Looks

Two files with the same size can behave very differently during compression.

Consider:
Dataset A
Large text files
Highly repetitive content

Dataset B
JPEG images
Already compressed

Dataset C
Random binary data
Poorly compressible


The resulting ZIP behavior can be very different.

For that reason, a serious benchmark should include data representative of the application's real workload.

For example, if the application generates PDF reports and images, benchmarking only repetitive text files will not tell you much about production behavior.

Testing Different Archive Sizes
A practical test matrix can use several archive sizes:
Small
~10 MB

Medium
~100 MB

Large
~500 MB


Application-specific
Real production-like dataset

These values are examples for designing the test, not expected benchmark results.
The important part is to observe how the encryption overhead changes as the workload grows.

Compression Level Also Matters
Encryption is not the only factor affecting ZIP performance.
Compression level can change CPU usage and archive creation time.

For example:
CompressionLevel.Fastest

and:
CompressionLevel.Optimal

represent different trade-offs.

If one benchmark uses Fastest without encryption and another uses Optimal with AES-256, the result does not isolate encryption.

Keep compression settings identical when measuring encryption overhead.

File Count Can Affect Performance

Archive size is not the only variable.

Consider two archives:
Archive A
1 file
500 MB

Archive B
50,000 files
500 MB total


They have the same approximate data size but very different metadata and filesystem workloads.
The second archive may involve substantially more file-opening, metadata, and directory operations.
A useful benchmark should therefore document both total data size and file count.

Testing AES-128 and AES-256

If the application has flexibility in its encryption choice, compare the AES variants separately.

For example:
var options = new ZipFileCreationOptions
{
Password = password.AsMemory(),
EncryptionMethod = ZipEncryptionMethod.Aes256,
CompressionLevel = CompressionLevel.Optimal
};

Then repeat the test using the appropriate AES method.
Do not automatically choose an algorithm based only on benchmark timing.
AES-256 is Microsoft's recommended choice for new encrypted ZIP archives.

If compatibility requirements force a different algorithm, document that requirement clearly.

Testing Password Failures

Performance testing should not be limited to successful extraction.

An application should also test what happens when the password is incorrect.

For example:
try
{
using ZipArchive archive =
    ZipFile.OpenRead("encrypted.zip");

foreach (var entry in archive.Entries)
{
    using var stream =
        entry.Open("incorrect-password");

    stream.CopyTo(Stream.Null);
}
}
catch (Exception ex)
{
Console.WriteLine(
    $"Archive could not be opened: {ex.Message}");
}


The exact exception behavior should be verified against the .NET version being tested rather than assuming that every incorrect-password scenario produces the same exception type.

Common Mistakes
Using ZipCrypto for New Security-Sensitive Archives

ZipCrypto is retained for compatibility, but it has known cryptographic weaknesses.

Use AES-256 for new archives unless an interoperability requirement prevents it.
Hard-Coding Passwords

This:
var password = "MySecret123";

is acceptable for a demonstration but not for production secret management.

Use a secure secret-management mechanism.

Benchmarking Only Archive Creation
Extraction can have different performance characteristics.

Measure both when the application performs both operations.

Using Only One Type of Data
Compression behavior depends heavily on the input.

Use realistic data.

Comparing Different Compression Levels

Keep compression settings identical when measuring encryption overhead.

Treating Archive Size as a Security Metric
A smaller archive does not mean a more secure archive. Security and compression are different concerns.

Troubleshooting
If an encrypted archive cannot be opened, check:

  • The archive was actually created with encryption.
  • The password is correct.
  • The encryption method is supported.
  • The application is using a compatible .NET version.
  • The ZIP tool used to create the archive is compatible with the selected encryption format.
  • The entry is not using an unsupported encryption method.

Microsoft documents that unsupported encryption methods can be reported through ZipEncryptionMethod.Unknown, and attempting to open an unsupported encrypted entry can result in NotSupportedException.

If performance is unexpectedly poor, inspect:

  • Compression level
  • Number of files
  • File sizes
  • Storage speed
  • CPU utilization
  • Encryption method
  • Archive size
  • Concurrent archive operations

Production Considerations
Encrypted ZIP support is useful for applications that generate archives containing sensitive information.

Typical examples include:

  • Exported reports
  • Customer document bundles
  • Backup packages
  • Diagnostic packages
  • Data exchange files

But password-protecting a ZIP file does not automatically solve every data-security problem.

Consider how the password is:

  • Generated
  • Stored
  • Delivered
  • Rotated
  • Revoked
  • Protected from logging

For example, avoid:
logger.LogInformation(
"Archive password: {Password}",
password);


Logging the password defeats much of the purpose of encrypting the archive.

Also consider whether sending the password through the same communication channel as the archive provides meaningful protection.

Best Practices
Prefer AES-256

For new encrypted ZIP archives, use AES-256 unless compatibility requirements dictate otherwise.

Keep Passwords Out of Source Code
Use secure configuration and secret-management mechanisms.

Benchmark Realistic Data
Include representative file types, sizes, and file counts.

Separate Compression and Encryption Tests
Measure unencrypted, encrypted, and different encryption methods independently.

Measure Creation and Extraction
Applications that support both operations should benchmark both.

Test Under Concurrency

If multiple users can generate archives simultaneously, test the effect on CPU, memory, storage, and application throughput.

Document the Exact Environment
Record the .NET SDK version, operating system, hardware, compression settings, encryption method, and test dataset.

Advantages

  • Provides built-in encrypted ZIP support in .NET 11.
  • Supports AES encryption options.
  • Reduces the need for third-party ZIP libraries for supported scenarios.
  • Integrates encryption with existing System.IO.Compression APIs.
  • Supports encrypted archive creation and extraction.
  • Allows applications to inspect an entry's encryption method.

Disadvantages

  • Encryption adds processing work.
  • Archive performance depends heavily on input data and file count.
  • Compatibility can vary between ZIP tools and encryption methods.
  • Password management becomes an application security responsibility.
  • Preview APIs may change while .NET 11 is still under development.
  • Encryption does not remove the need for secure data handling elsewhere in the application.

Conclusion
.NET 11 brings password-protected ZIP archives into the built-in .NET compression APIs, making encrypted archive workflows much easier to implement without automatically reaching for a third-party library.

The performance question is more nuanced.

Encryption adds processing work, but the actual impact depends on archive size, file count, compression level, encryption method, storage performance, and concurrency. That is why a useful benchmark should measure creation and extraction separately and should include realistic application data.

For new encrypted archives, AES-256 should be the default choice unless compatibility requirements require another method. ZipCrypto is a legacy option and should not be selected merely because a benchmark happens to show lower processing cost.

The best way to evaluate the feature is to build a controlled benchmark, keep the input and compression settings consistent, measure CPU and memory alongside execution time, and test the workload your application will actually handle.

That gives developers something much more useful than a generic claim that encrypted ZIP files are "fast" or "slow": concrete evidence about the cost of protecting their own data.

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 :: Send Gmail Emails from ASP.NET Core with MailKit, Refresh Token, and OAuth 2.0

clock August 26, 2026 11:51 by author Peter

ERP, CRM, HR, notice, invoicing, and reporting apps frequently need to be able to send emails from an ASP.NET Core application. Applications used to authenticate SMTP using a Gmail username and password. This method of application authentication is no longer advised by Gmail. Alternatively, an application may be safely authenticated using OAuth 2.0 without requiring the Gmail account password to be stored.

We will use the following to develop Gmail email sending in C# in this article:

1. Architecture
The email-sending flow is:
ASP.NET Core Application
        |
        | ClientId
        | ClientSecret
        | RefreshToken
        | Gmail Address
        v
Google OAuth 2.0
        |
        | Access Token
        v
MailKit SMTP Client
        |
        | smtp.gmail.com:587
        | STARTTLS
        | OAuth2
        v
Gmail SMTP Server
        |
        v
Recipient


The important point is that the application does not use the Gmail password.

Instead, the application stores the OAuth refresh token and uses it to obtain an access token when required.

2. Required NuGet Packages

The following packages are required.
Google.Apis.Auth
MailKit
MimeKit

For example:
Install-Package Google.Apis.Auth
Install-Package MailKit
Install-Package MimeKit

3. Required Google OAuth Information
The application requires the following values:

  • Gmail Address
  • Client ID
  • Client Secret
  • Refresh Token

For example:
FromEmail      = [email protected]
ClientId       = xxxxxxxxxxxxx.apps.googleusercontent.com
ClientSecret   = xxxxxxxxxxxxx
RefreshToken   = xxxxxxxxxxxxx


The refresh token is particularly important because it allows the application to obtain a new access token when the current access token expires.

4. Email Model

The application uses an EmailSnd object to pass email information to the email sender.

A simplified model can look like this:
public class EmailSnd
{
    public string FrmEmailId { get; set; }
    public string ToEmailId { get; set; }
    public string CC { get; set; }

    public string Subject { get; set; }
    public string Body { get; set; }

    public string RefreshToken { get; set; }
    public string AccessToken { get; set; }

    public string ClientId { get; set; }
    public string ClientSecret { get; set; }

    public string SMTPAdrs { get; set; }
    public int PortNo { get; set; }

    public string DownloadUrl { get; set; }
}


Your actual EmailSnd class can contain additional ERP-specific properties.

5. Gmail Email Sender Class

The main implementation is the GmailEmailSender class.
using Google.Apis.Auth.OAuth2;
using Google.Apis.Auth.OAuth2.Responses;
using Google.Apis.Auth.OAuth2.Flows;
using Google.Apis.Util;

using MimeKit;
using MailKit.Security;

using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;

namespace iSuiteCoreV1.Utilities.EmailSender
{
    public class GmailEmailSender
    {
        public static bool IsValidEmail(string email)
        {
            try
            {
                var addr = new System.Net.Mail.MailAddress(email);
                return addr.Address == email;
            }
            catch
            {
                return false;
            }
        }

        public async Task SendEmailAsync(EmailSnd emlSndngList)
        {
            try
            {
                var fromEmail = emlSndngList.FrmEmailId;
                var toEmail = emlSndngList.ToEmailId;

                var tokenResponse = new TokenResponse
                {
                    RefreshToken = emlSndngList.RefreshToken
                };

                var flow =
                    new GoogleAuthorizationCodeFlow(
                        new GoogleAuthorizationCodeFlow.Initializer
                        {
                            ClientSecrets = new ClientSecrets
                            {
                                ClientId = emlSndngList.ClientId,
                                ClientSecret = emlSndngList.ClientSecret
                            },
                            Scopes = new[]
                            {
                                "https://mail.google.com/"
                            }
                        });

                var credential =
                    new UserCredential(
                        flow,
                        fromEmail,
                        tokenResponse);

                if (credential.Token.AccessToken == null ||
                    credential.Token.IsExpired(SystemClock.Default))
                {
                    bool success =
                        await credential.RefreshTokenAsync(
                            CancellationToken.None);

                    if (!success ||
                        string.IsNullOrEmpty(
                            credential.Token.AccessToken))
                    {
                        throw new InvalidOperationException(
                            "Failed to acquire access token via refresh token.");
                    }
                }

                var message = new MimeMessage();

                message.From.Add(
                    MailboxAddress.Parse(fromEmail));

                message.To.Add(
                    MailboxAddress.Parse(toEmail));

                message.Subject =
                    emlSndngList.Subject ?? "";

                var bodyBuilder = new BodyBuilder
                {
                    HtmlBody = emlSndngList.Body
                };

                if (!string.IsNullOrEmpty(emlSndngList.CC))
                {
                    var ccEmails =
                        emlSndngList.CC
                            .Split(
                                new[] { ',', ':' },
                                StringSplitOptions.RemoveEmptyEntries)
                            .Select(email => email.Trim());

                    foreach (var email in ccEmails)
                    {
                        if (IsValidEmail(email))
                        {
                            message.Cc.Add(
                                MailboxAddress.Parse(email));
                        }
                    }
                }

                message.Body =
                    bodyBuilder.ToMessageBody();

                using var smtpClient =
                    new MailKit.Net.Smtp.SmtpClient();

                await smtpClient.ConnectAsync(
                    "smtp.gmail.com",
                    587,
                    SecureSocketOptions.StartTls);

                var oauth2 =
                    new SaslMechanismOAuth2(
                        fromEmail,
                        credential.Token.AccessToken);

                await smtpClient.AuthenticateAsync(oauth2);

                await smtpClient.SendAsync(message);

                await smtpClient.DisconnectAsync(true);
            }
            catch
            {
                throw;
            }
        }
    }
}


6. Understanding the OAuth Flow
The most important part of the implementation is OAuth authentication.

Step 1: Create TokenResponse
var tokenResponse = new TokenResponse
{
    RefreshToken = emlSndngList.RefreshToken
};

Here we provide the previously generated refresh token.

The refresh token is long-lived compared to an access token and is used to obtain new access tokens.

7. Create Google Authorization Flow
var flow =
new GoogleAuthorizationCodeFlow(
    new GoogleAuthorizationCodeFlow.Initializer
    {
        ClientSecrets = new ClientSecrets
        {
            ClientId = emlSndngList.ClientId,
            ClientSecret = emlSndngList.ClientSecret
        },
        Scopes = new[]
        {
            "https://mail.google.com/"
        }
    });


The important values are:
ClientId
ClientSecret
Scope


The scope:
https://mail.google.com/

provides Gmail access required for SMTP OAuth authentication.

8. Create UserCredential
var credential =
new UserCredential(
    flow,
    fromEmail,
    tokenResponse);


The credential associates:
Gmail Account
+
OAuth Client
+
Refresh Token


9. Refresh the Access Token
An access token is temporary.

Therefore, before sending the email, we check whether an access token exists and whether it has expired.
if (credential.Token.AccessToken == null ||
    credential.Token.IsExpired(SystemClock.Default))
{
    bool success =
        await credential.RefreshTokenAsync(
            CancellationToken.None);

    if (!success ||
        string.IsNullOrEmpty(
            credential.Token.AccessToken))
    {
        throw new InvalidOperationException(
            "Failed to acquire access token via refresh token.");
    }
}


This is an important part of the implementation.

The application does not need to manually request a new access token every time.

Instead:
Refresh Token
      |
      v
Google OAuth Server
      |
      v
New Access Token
      |
      v
Gmail SMTP


10. Create the Email Message
MailKit uses MimeMessage to construct the email.
var message = new MimeMessage();

message.From.Add(
    MailboxAddress.Parse(fromEmail));

message.To.Add(
    MailboxAddress.Parse(toEmail));

message.Subject =
    emlSndngList.Subject ?? "";


This defines:
From
To
Subject

11. Send HTML Email
The application uses BodyBuilder .
var bodyBuilder = new BodyBuilder
{
    HtmlBody = emlSndngList.Body
};

message.Body =
    bodyBuilder.ToMessageBody();


This allows the application to send HTML content.

For example:
<h2>Purchase Order Notification</h2>

<p>Your purchase order has been approved.</p>

<table>
    <tr>
        <td>PO Number</td>
        <td>PO-10001</td>
    </tr>
</table>


This is useful for ERP applications where notification emails need formatting.

12. Adding CC Recipients

The implementation accepts multiple CC addresses.
if (!string.IsNullOrEmpty(emlSndngList.CC))
{
    var ccEmails =
        emlSndngList.CC
            .Split(
                new[] { ',', ':' },
                StringSplitOptions.RemoveEmptyEntries)
            .Select(email => email.Trim());

    foreach (var email in ccEmails)
    {
        if (IsValidEmail(email))
        {
            message.Cc.Add(
                MailboxAddress.Parse(email));
        }
    }
}


For example:
[email protected],[email protected]

The application splits the addresses and adds them individually.

13. Connect to Gmail SMTP
The Gmail SMTP server is:
smtp.gmail.com

The implementation uses port:
587

with STARTTLS.
await smtpClient.ConnectAsync(
"smtp.gmail.com",
587,
SecureSocketOptions.StartTls);


The connection flow is:
Application
   |
   | STARTTLS
   v
smtp.gmail.com:587


14. OAuth2 SMTP Authentication
Instead of username/password authentication, the application uses OAuth 2.0.
var oauth2 =
    new SaslMechanismOAuth2(
        fromEmail,
        credential.Token.AccessToken);

await smtpClient.AuthenticateAsync(oauth2);


The important point is:
Gmail Address
+
OAuth Access Token
=
SMTP Authentication


No Gmail password is passed to MailKit.

15. Send the Email
Once authentication succeeds:
await smtpClient.SendAsync(message);

Then disconnect:
await smtpClient.DisconnectAsync(true);

The complete sequence is:
Create Email
     |
     v
Get Refresh Token
     |
     v
Generate Access Token
     |
     v
Connect Gmail SMTP
     |
     v
OAuth2 Authentication
     |
     v
Send Email
     |
     v
Disconnect


16. Calling the Email Sender from the Application

Your ERP application creates the EmailSnd object.
EmailSnd emailSending = new EmailSnd();

emailSending.FrmEmailId =
    emailSetups.FromEmail;

emailSending.SMTPAdrs =
    emailSetups.SmtpServer;

emailSending.PortNo =
    emailSetups.PortNo;

emailSending.RefreshToken =
    emailSetups.RefreshToken;

emailSending.AccessToken =
    emailSetups.AccessToken;

emailSending.ClientSecret =
    emailSetups.ClientSecret;

emailSending.ClientId =
    emailSetups.ClientId;

emailSending.DownloadUrl =
    text;

emailSending.Body =
    text;

emailSending.Subject =
    notificationMessage;

emailSending.ToEmailId =
    toEmail;

await _emailSender.SendEmailAsync(emailSending);


Important
In your original code you have:
_emailSender.SendEmailAsync(emailSending);

Because SendEmailAsync() is asynchronous, it is better to use:
await _emailSender.SendEmailAsync(emailSending);

Otherwise, the caller does not wait for the email operation to finish.

17. Dependency Injection

In an ASP.NET Core application, the sender can be registered with dependency injection.

For example:
builder.Services.AddScoped<GmailEmailSender>();

Then inject it into your service/controller:
private readonly GmailEmailSender _emailSender;

public NotificationService(
    GmailEmailSender emailSender)
{
    _emailSender = emailSender;
}


Then:
await _emailSender.SendEmailAsync(emailSending);

18. Recommended Database Configuration
For an ERP application, Gmail configuration can be stored in an email setup table.

For example:
EmailSetup
--------------------------------
Id
FromEmail
SmtpServer
PortNo
ClientId
ClientSecret
RefreshToken
AccessToken
IsActive

Example:
FromEmail     : [email protected]
SmtpServer    : smtp.gmail.com
PortNo        : 587
ClientId      : Google Client ID
ClientSecret  : Google Client Secret
RefreshToken  : OAuth Refresh Token


This allows the ERP application to load the email configuration dynamically.

19. Multiple Gmail Accounts

This architecture can also support multiple Gmail accounts.

For example:
Company A
    |
    +-- gmail account
    +-- Client ID
    +-- Client Secret
    +-- Refresh Token

Company B
    |
    +-- gmail account
    +-- Client ID
    +-- Client Secret
    +-- Refresh Token

When sending an email, the application selects the appropriate email configuration.

This is useful for multi-company ERP applications.

20. Security Considerations

OAuth credentials are sensitive.

The following values should NOT be exposed in Angular, JavaScript, browser local storage, or source control:
ClientSecret
RefreshToken
AccessToken

They should remain on the server.

For production applications, consider storing sensitive credentials using:

  • Environment variables
  • Secure database encryption
  • ASP.NET Core Secret Manager for development
  • Do not commit OAuth credentials to GitHub.

21. Error Handling
Your original code contains:
catch (Exception ex)
{
    throw ex;
}


This should be changed to:
catch
{
    throw;
}

or, if logging is required:
catch (Exception ex)
{
    // Log exception
    throw;
}


throw; preserves the original stack trace, while throw ex; can reset the stack-trace information.

22. Common Errors
Invalid Grant
invalid_grant


Possible reasons include:

  • Refresh token revoked
  • OAuth consent changed
  • Google account security settings changed
  • Incorrect Client ID / Client Secret
  • Refresh token belongs to a different OAuth client

A new OAuth authorization may be required.

Failed to Acquire Access Token

If this code fails:
await credential.RefreshTokenAsync(
CancellationToken.None);


verify:
ClientId
ClientSecret
RefreshToken

Authentication Failed
If MailKit reports SMTP authentication failure, check:
smtp.gmail.com
Port 587
STARTTLS
OAuth2 access token

Also verify that the Gmail account and OAuth configuration are valid.

23. Recommended Improvement
Your model currently contains both:

  • AccessToken
  • RefreshToken

In a refresh-token-based architecture, the refresh token is the important persistent credential.

The access token is temporary and can be regenerated.

Therefore, the application can generally follow:
Database
   |
   +-- ClientId
   +-- ClientSecret
   +-- RefreshToken
   |
   v
Google OAuth
   |
   v
Access Token
   |
   v
MailKit
   |
   v
Gmail


The access token does not necessarily need to be permanently stored unless your application has a specific reason to cache it.

24. Complete Email Sending Flow
The complete implementation can be summarized as:
User / ERP Transaction
        |
        v
Notification Service
        |
        v
EmailSnd Object
        |
        v
GmailEmailSender
        |
        +---- Client ID
        |
        +---- Client Secret
        |
        +---- Refresh Token
        |
        v
Google OAuth 2.0
        |
        v
Access Token
        |
        v
MailKit
        |
        v
smtp.gmail.com:587
        |
        | OAuth2 + STARTTLS
        v
Gmail
        |
        v
Recipient


25. Advantages of This Approach
1. No Gmail Password
The application does not need the Gmail account password.

2. OAuth 2.0
Authentication is performed using Google's OAuth mechanism.

3. Refresh Token
The application can obtain new access tokens when required.

4. HTML Email
MailKit/MimeKit supports rich HTML email content.

5. CC Support
Multiple CC recipients can be supported.

6. ERP Friendly
Email configuration can be maintained per company, branch, or email account.

7. Server-Side Security
OAuth credentials remain inside the ASP.NET Core application.

26. Final Code Recommendation

One small change I strongly recommend in your calling code is:
await _emailSender.SendEmailAsync(emailSending);

instead of:
_emailSender.SendEmailAsync(emailSending);

Also change:
catch (Exception ex)
{
    throw ex;
}


to:
catch
{
    throw;
}

This gives you a cleaner asynchronous implementation and preserves the original exception stack trace.
Conclusion

Gmail SMTP can be integrated into an ASP.NET Core ERP application without storing the Gmail password.

The application uses:
Google OAuth 2.0
        +
Refresh Token
        ↓
Access Token
        ↓
MailKit
        ↓
Gmail SMTP
        ↓
Email


This approach is particularly useful for ERP systems that need to send purchase-order notifications, invoice emails, approval notifications, reports, alerts, and other transactional emails.

The main credentials that need to be protected are:

  • Client ID
  • Client Secret
  • Refresh Token


The refresh token should be treated as a sensitive credential and should never be exposed to the browser or committed to source control.

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 :: Comparing Compiled EF Core Queries on Big Production-Style Datasets

clock August 18, 2026 11:51 by author Peter

Although Entity Framework Core greatly simplifies database access for.NET applications, ease of use does not guaranty optimal query execution. Compiled queries are one improvement that frequently surfaces while examining EF Core query speed. Prior to executing the necessary database operation, EF Core typically needs to perform a LINQ query. Compiled queries might lessen some of the repetitive query-processing cost for frequently conducted queries, particularly in high-throughput systems.

Whether compiled queries are theoretically quicker is not the crucial question. Whether they have a discernible impact on the real workload of your application is a worthwhile question. A suitable benchmark is necessary for it.On a development laptop, a test with 100 rows can demonstrate an improvement that vanishes when the program is linked to a production-sized database. On the other hand, when a query runs hundreds or thousands of times per second, a tiny per-call savings may become significant.

This article describes how EF Core compiled queries operate, how to use big production-style datasets to compare them against standard LINQ queries, what measures are important, and when it makes sense to introduce the optimization.

What Are EF Core Compiled Queries?
A normal EF Core query typically starts with a LINQ expression:
var customer = await db.Customers
    .Where(x => x.Id == customerId)
    .SingleOrDefaultAsync();


EF Core has to process the expression and translate it into a database command.

For example, conceptually:
LINQ expression
      |
      v
EF Core query processing
      |
      v
SQL command
      |
      v
Database


When the same query shape is executed repeatedly, EF Core can reuse internal query compilation information. However, an application can also explicitly use a compiled query.

For example:
private static readonly Func<AppDbContext, int, Customer?>
    GetCustomer =
        EF.CompileQuery(
            (AppDbContext db, int id) =>
                db.Customers
                  .SingleOrDefault(x => x.Id == id));

The query can then be executed as:
var customer = GetCustomer(db, customerId);

The key idea is that the query expression is compiled once and reused.

Why Benchmarking Is Necessary
It is tempting to look at compiled queries as a simple performance switch:
Normal LINQ -> slower
Compiled query -> faster


Real applications are more complicated.

The total request time may include:

  • Network latency.
  • Database execution time.
  • Index lookups.
  • Query result materialization.
  • Object allocation.
  • Application-side processing.
  • Connection acquisition.
  • Transaction handling.
  • EF Core query-processing overhead.

If the database takes 20 milliseconds to execute a query and compiled-query processing saves only a small fraction of a millisecond, the optimization may have little impact on end-to-end latency. On the other hand, if the query itself is very fast and executes extremely frequently, EF Core overhead can become a meaningful part of the total cost. That is why compiled-query benchmarks should isolate the right variables.

Benchmark the Right Workload
A production-style benchmark should not simply create a few hundred records and execute one query repeatedly.

Instead, construct a dataset that resembles the application's real access patterns. 
For example, an order-processing system might contain:

TableApproximate Records
Customers 1,000,000
Orders 10,000,000
OrderItems 30,000,000
Products 500,000
Payments 8,000,000

The exact numbers are not important. What matters is that the database is large enough to exercise realistic indexes, query plans, buffer usage, and result sets. The benchmark should also use realistic distributions rather than generating completely uniform data.

Choose Representative Queries
Compiled queries are most interesting for queries that execute frequently.

Good candidates include:
var customer = await db.Customers
    .SingleOrDefaultAsync(x => x.Id == customerId);

or:
var orders = await db.Orders
    .Where(x => x.CustomerId == customerId)
    .OrderByDescending(x => x.CreatedAt)
    .Take(20)
    .ToListAsync();


Another example is a lookup involving several predicates:
var order = await db.Orders
    .Where(x =>
        x.CustomerId == customerId &&
        x.Status == OrderStatus.Pending)
    .OrderBy(x => x.CreatedAt)
    .FirstOrDefaultAsync();

These queries are suitable candidates because their shapes remain stable while their parameters change.

Compiled queries are generally less useful when the query structure itself changes dynamically for every request.

A Normal EF Core Query

Start with the conventional implementation.
public async Task<Order?> GetOrderAsync(
    AppDbContext db,
    long orderId,
    CancellationToken cancellationToken)
{
    return await db.Orders
        .AsNoTracking()
        .Where(x => x.Id == orderId)
        .SingleOrDefaultAsync(cancellationToken);
}

Using AsNoTracking() is intentional in this benchmark when the operation is read-only. The benchmark should compare equivalent workloads. If one version uses tracking and another does not, the results do not isolate compiled-query performance.

Create the Compiled Query
The equivalent compiled query can be defined once:
private static readonly Func<
    AppDbContext,
    long,
    Order?> GetOrderCompiled =
        EF.CompileQuery(
            (AppDbContext db, long orderId) =>
                db.Orders
                  .AsNoTracking()
                  .SingleOrDefault(
                      x => x.Id == orderId));


The call site becomes:
var order =
    GetOrderCompiled(db, orderId);


For asynchronous execution, use the appropriate compiled asynchronous API:
private static readonly Func<
    AppDbContext,
    long,
    Task<Order?>> GetOrderCompiledAsync =
        EF.CompileAsyncQuery(
            (AppDbContext db, long orderId) =>
                db.Orders
                  .AsNoTracking()
                  .SingleOrDefault(
                      x => x.Id == orderId));


Then:
var order =
    await GetOrderCompiledAsync(db, orderId);

The normal and compiled versions should produce equivalent database behavior.

Keep the Database Constant
One of the most important benchmark rules is to avoid changing multiple variables simultaneously.

If the normal query uses:
Database A
.NET version X
EF Core version Y


and the compiled query uses:
Database B
.NET version X
EF Core version Y


the comparison is already compromised.

Use the same:

  • Database server.
  • Database version.
  • Dataset.
  • Indexes.
  • Connection configuration.
  • EF Core version.
  • .NET runtime.
  • Query parameters.
  • Hardware or virtual machine.
  • Network path.

Only change the query execution strategy.

Benchmark Against a Real Database

EF Core query performance should generally be measured against the database engine that the application actually uses.
An in-memory provider does not reproduce the behavior of a real relational database.

A production-style benchmark should therefore look more like:
Benchmark application
        |
        v
EF Core
        |
        v
Database provider
        |
        v
Real database

This allows the benchmark to include actual network communication, SQL execution, query planning, indexing, and result materialization.

Use BenchmarkDotNet Carefully
BenchmarkDotNet is useful for controlled performance experiments, but database benchmarks require additional discipline.

A simplified benchmark could look like:
[MemoryDiagnoser]
public class EfCoreQueryBenchmark
{
    private AppDbContext _db = null!;
    private long _orderId;

    [GlobalSetup]
    public void Setup()
    {
        _db = CreateDbContext();
        _orderId = 5000000;
    }

    [Benchmark(Baseline = true)]
    public async Task<Order?> NormalQuery()
    {
        return await _db.Orders
            .AsNoTracking()
            .SingleOrDefaultAsync(
                x => x.Id == _orderId);
    }

    [Benchmark]
    public async Task<Order?> CompiledQuery()
    {
        return await GetOrderCompiledAsync(
            _db,
            _orderId);
    }
}

However, this benchmark needs careful interpretation because database calls introduce external state, network latency, connection behavior, caching, and database-side variability. For database benchmarks, it is often useful to complement BenchmarkDotNet with application-level load tests and database metrics.

Warm-Up Matters
The first execution of a query can behave differently from later executions.

Factors include:

  • Application initialization.
  • EF Core internal query processing.
  • Database plan preparation.
  • Connection establishment.
  • Database buffer cache.
  • Operating-system cache.

Therefore, do not compare only the first execution. A useful test should include a warm-up phase followed by measured iterations.

Conceptually:
Initialization
     |
     v
Warm-up executions
     |
     v
Measured executions
     |
     v
Statistical analysis


This makes the results more representative of a long-running service.

Cold and Warm Performance Are Different Questions

A production application may care about both. For example, a serverless or short-lived workload may repeatedly experience application startup. A long-running ASP.NET Core service may execute the same query thousands of times after startup.

Therefore benchmark:

ScenarioWhat It Measures
Cold execution Startup and first-use behavior
Warm execution Steady-state query performance
Repeated execution Throughput and consistency
Concurrent execution Behavior under load

Do not combine these into a single number.

Measure More Than Average Time
Average execution time is useful but incomplete.

Capture at least:

  • Mean latency.
  • Median latency.
  • p95 latency.
  • p99 latency.
  • Throughput.
  • Allocations.
  • CPU usage.
  • Database CPU.
  • Database logical reads.
  • Error rate.

For example:

MetricNormal LINQCompiled Query
Mean Measure Measure
Median Measure Measure
p95 Measure Measure
p99 Measure Measure
Allocations Measure Measure
DB logical reads Measure Measure
Throughput Measure Measure

Do not publish invented numbers simply to demonstrate an improvement. The benchmark should produce the numbers.

Database Execution Time Can Dominate
Suppose your application performs:
EF Core processing:     0.20 ms
Network + DB execution: 8.50 ms
Materialization:        0.40 ms


Even if compiled queries reduce EF Core processing significantly, the total request may change very little.

This is why query optimization should start with profiling.
If the SQL query itself is slow, investigate:

  • Indexes.
  • Query shape.
  • Joins.
  • Cardinality.
  • Query plan.
  • Database statistics.
  • Returned rows.

A compiled query should not be used to hide a poorly designed database query.

Use Large and Small Result Sets
Benchmark different result sizes.

For example:
Single row
10 rows
100 rows
1,000 rows
10,000 rows

This helps determine where EF Core overhead matters. For a single-row indexed lookup, application-side overhead may represent a larger portion of total execution time. For a query returning thousands of rows, database execution and materialization can dominate. That difference is important when deciding whether compiled queries are worthwhile.

Test Different Parameter Values
A benchmark should not repeatedly query exactly the same record.

For example:
var random = Random.Shared.NextInt64(
    1,
    10_000_000);


Then use different IDs across iterations.

This can provide a more representative workload, although deterministic parameter sequences may be preferable for controlled benchmark comparisons.

The important thing is to understand whether the database workload is sensitive to parameter distribution.

Avoid Accidental Query Differences

A benchmark becomes meaningless if the two queries are not equivalent.

For example, this:
db.Orders
    .AsNoTracking()
    .Where(x => x.CustomerId == customerId)


should not be compared against:
db.Orders
    .Where(x => x.CustomerId == customerId)
    .Include(x => x.Items)


because the second query performs substantially different work.

Keep the query shape equivalent.

Check Generated SQL

Before interpreting benchmark results, inspect the SQL generated by the normal query. You can use EF Core logging to examine the generated commands. The goal is to verify that both approaches ultimately execute equivalent SQL.

For example:
SELECT ...
FROM Orders
WHERE Id = @p0


If the SQL differs, investigate why before attributing the performance difference to compiled queries.

Test Query Compilation Overhead Separately
One of the most useful experiments is to compare repeated execution against first-use behavior.

Conceptually:
Experiment A
Normal query, first execution

Experiment B
Normal query, repeated execution

Experiment C
Compiled query, first execution

Experiment D
Compiled query, repeated execution


This helps identify whether the optimization primarily affects initial query-processing overhead or produces a meaningful steady-state improvement.

Benchmark Concurrent Requests
A production API rarely executes one query at a time.

Consider:
100 concurrent requests
        |
        +-- Query A
        +-- Query B
        +-- Query C
        ...


Run concurrency tests such as:
1 concurrent request
10 concurrent requests
50 concurrent requests
100 concurrent requests
500 concurrent requests

The exact levels depend on the application's expected workload.

Measure both application and database behavior.

A compiled query that improves single-request latency but provides no meaningful throughput improvement under realistic concurrency may not justify additional complexity.

Watch Database Connection Pooling
Connection pooling can influence benchmark results. If the benchmark repeatedly opens and closes contexts, make sure you understand what is happening with the underlying connections. You should decide whether the experiment is intended to measure:

Query execution with warm connections

or:

End-to-end operation including connection acquisition
Both can be useful, but they answer different questions.

Benchmark With Tracking Disabled and Enabled

For read-heavy workloads, AsNoTracking() is common. However, applications sometimes require tracked entities.

If the production workload uses tracking, benchmark tracking.

For example:
db.Orders
    .Where(x => x.Id == orderId)
    .SingleOrDefaultAsync();


and:
db.Orders
    .AsNoTracking()
    .Where(x => x.Id == orderId)
    .SingleOrDefaultAsync();

Do not optimize a no-tracking benchmark and then assume the result applies directly to a tracking-heavy workload.
Measure Memory Allocations

Use memory diagnostics where appropriate.

For BenchmarkDotNet:
[MemoryDiagnoser]
public class QueryBenchmark
{
    // Benchmark methods
}

This can help identify allocation differences.

However, do not assume that fewer allocations automatically mean better application performance.

Garbage collection pressure, database latency, CPU consumption, and throughput should be considered together.

Production-Style Dataset Design

A realistic benchmark dataset should contain meaningful relationships.

For example:
Customers
   |
   +---- Orders
           |
           +---- OrderItems
                    |
                    +---- Products

Populate the dataset with:

  • Different customer sizes.
  • Customers with no orders.
  • Customers with many orders.
  • Recent and old orders.
  • Different order statuses.
  • Realistic date distributions.
  • Null and optional values where applicable.

This helps expose query behavior that a synthetic dataset with identical rows may hide.

Common Mistakes
Benchmarking Only 100 Rows

A tiny database often does not represent production behavior.

Using an In-Memory Provider
It does not reproduce real relational database execution.

Measuring Only the First Request

Cold execution can distort conclusions about steady-state performance.

Measuring Only Average Latency

Tail latency can reveal problems hidden by averages.

Changing Multiple Variables

Do not simultaneously change indexes, database versions, query shape, and EF Core behavior.

Optimizing Before Profiling
If the database query takes most of the request time, compiled queries may have little impact.

Reusing One Parameter Forever

Repeatedly querying the same row may produce cache behavior that does not represent production.

Ignoring Concurrency

A single-threaded benchmark may not reveal the behavior of a high-throughput API.

Troubleshooting Unexpected Benchmark Results

If compiled queries show almost no improvement, that is not necessarily a failed experiment.

First determine where the time is being spent.

For example:

  • Application query processing
  • Database execution
  • Network
  • Materialization
  • Serialization

If database execution dominates, optimize the SQL and database design first. If the application spends a measurable amount of time processing repeated query expressions, compiled queries may become more interesting.

If results vary significantly between runs, investigate:

  • Database cache state.
  • Concurrent workload.
  • Connection pooling.
  • CPU contention.
  • Network variability.
  • Garbage collection.
  • Parameter distribution.

Run multiple benchmark sessions rather than relying on a single execution.

When Compiled Queries Make Sense

Compiled queries are most interesting when all of the following are reasonably true:

  • The query is executed frequently.
  • The query shape is stable.
  • The database query itself is already efficient.
  • EF Core query-processing overhead is measurable.
  • The performance improvement matters to the application's SLA or throughput.
  • The additional code complexity is acceptable.

Typical candidates can include high-volume APIs, frequently executed lookups, hot-path data-access operations, and services where database queries are already highly optimized.

When They May Not Be Worth It

Compiled queries may provide limited practical value when:

  • The query executes infrequently.
  • Database latency dominates the request.
  • The query shape changes frequently.
  • The application already has sufficient performance.
  • The code becomes significantly harder to maintain.
  • The measured improvement is too small to matter.

A small benchmark improvement does not automatically justify introducing specialized data-access patterns throughout an entire codebase.

A Practical Benchmark Matrix
A useful benchmark plan might look like this:

DimensionValues
Query strategy Normal / Compiled
Dataset Small / Medium / Large
Result size 1 / 10 / 100 / 1,000
Tracking On / Off
Concurrency 1 / 10 / 50 / 100
Execution state Cold / Warm
Parameter distribution Fixed / Variable

This produces a much stronger performance picture than a single benchmark.

How to Interpret the Results
Suppose the benchmark shows:

Normal query:
Mean: 2.10 ms

Compiled query:
Mean: 1.95 ms

The difference is approximately:
0.15 ms

That may look useful.

But ask another question:
How often does this query execute?

If it runs 10 times per minute, the improvement is probably irrelevant.
If it runs 10,000 times per second, the same difference could become significant.
The business and operational context matters as much as the benchmark number.

Frequently Asked Questions
Are compiled queries always faster?
No. They are designed to reduce some query-processing overhead, but the overall benefit depends on the workload. Database execution, network latency, materialization, and other costs can dominate total time.

Should every EF Core query use compiled queries?

No. Applying compiled queries everywhere can add unnecessary complexity. They are better suited to stable, frequently executed hot-path queries where profiling demonstrates a measurable benefit.

Do compiled queries make SQL execution faster?

Not necessarily. The primary optimization is around EF Core's query processing. The database still has to execute the resulting SQL.

Are compiled queries useful for large result sets?

They can be, but the benefit may become relatively smaller as database execution and result materialization dominate the total operation.

Should compiled queries be used before adding database indexes?

Usually not. If a query is slow because the database lacks an appropriate index or has an inefficient query plan, fix the database-side problem first.

Can BenchmarkDotNet be used with EF Core?

Yes, but database benchmarks require careful setup because database state, caching, network latency, connection pooling, and external workload can influence measurements. BenchmarkDotNet should be combined with appropriate database and application-level measurements.

Conclusion
EF Core compiled queries are a targeted optimization, not a universal replacement for normal LINQ queries. Their value becomes clear only when they are measured against realistic workloads and the actual database behavior is understood. A useful benchmark should use a production-style dataset, equivalent query shapes, a real database provider, warm and cold scenarios, realistic parameter distributions, and representative concurrency. It should measure latency, tail behavior, allocations, throughput, and database-side work rather than focusing on a single average number.

The most important outcome of the benchmark is not proving that compiled queries are faster. It is identifying whether EF Core query-processing overhead is significant enough in your application's hot paths to justify the additional complexity. When the database query is already optimized and the same query shape executes at high volume, compiled queries can be a useful tool. When database execution dominates the workload, indexes, query design, schema changes, and database tuning are usually more valuable places to start.

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 Runtime Async Regressions in .NET 11

clock August 13, 2026 13:07 by author Peter

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.




European ASP.NET Core 10.0 Hosting - HostForLIFE :: Using .NET to Create a Cloud-Based Smart Locker Management System

clock August 10, 2026 13:09 by author Peter

Smart locker systems are becoming more and more prevalent in logistical operations, residential communities, businesses, and educational institutions. The underlying software is in charge of real-time communication, package tracking, locker allocation, and authentication, even tho consumers usually interact with a touchscreen interface or receive notifications on their mobile devices.

An overview of the architecture of a cloud-based smart locker management system developed with the.NET environment is given in this article.

Core Components
A modern smart locker platform typically consists of the following components:

  • A cloud-hosted backend API
  • A relational database for storing users, lockers, and transaction records
  • Locker controller software that communicates with physical locker hardware
  • A web-based administration portal
  • Mobile or email notification services

These components communicate securely using REST APIs or message-based communication, depending on the system architecture and deployment requirements.

Authentication and Security
Authentication and authorization play a critical role in ensuring that only authorized users can access assigned lockers. A secure implementation commonly includes:

  • OAuth 2.0 or OpenID Connect for user authentication
  • JWT-based authentication for API access
  • Role-based authorization
  • One-time PIN generation for temporary access
  • Audit logging for locker access and administrative operations

These security measures help protect user data while maintaining a complete audit trail of locker activities.

Real-Time Operations

Cloud-hosted services enable several real-time capabilities, including:

  • Automatic locker assignment
  • Instant delivery or pickup notifications
  • Live locker status monitoring
  • Remote administration and diagnostics
  • Event logging and reporting

Depending on application requirements, real-time communication can be implemented using ASP.NET Core SignalR or cloud-based messaging services.

Why Choose .NET?

The .NET platform provides several capabilities that make it well suited for enterprise applications:

  • Cross-platform development with ASP.NET Core
  • High-performance Web APIs
  • Built-in security features
  • Seamless integration with cloud platforms such as Azure
  • Support for scalable microservice architectures
  • Rich development tools and a mature ecosystem

These features enable developers to build secure, scalable, and maintainable applications for enterprise environments.

High-Level Architecture
A typical cloud-based smart locker solution includes the following layers:

  • Presentation Layer – Web and mobile applications used by administrators and end users.
  • API Layer – ASP.NET Core Web APIs that expose business functionality securely.
  • Business Layer – Services responsible for locker allocation, authentication, package management, and business rules.
  • Data Layer – A relational database that stores user information, locker details, access logs, and transaction history.
  • Locker Controller Layer – Software that communicates with the physical locker hardware.
  • Notification Layer – Email, SMS, or push notification services used to inform users about locker events.
  • This layered architecture helps improve maintainability, scalability, and separation of concerns.

Conclusion
Controlling physical lockers is just one aspect of developing a cloud-based smart locker management system. A dependable corporate solution includes centralized management, alerts, cloud services, secure APIs, authentication, and monitoring.

Developers may create scalable apps that provide safe package management, workplace storage, IT asset management, educational campuses, and other intelligent storage activities by utilizing contemporary.NET technology.



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