Meet The Author

I'm Ethan Jackson, An 25 years old blogger Currently living in London, United Kingdom. I'm a Skilled Blogger, Part Time web Developer And Creating new things as a web Designer.

author

ASP.NET Tutorial: Let's Use Distributed Systems Principles to Improve the Design of a.NET Background Service

Leave a Comment

You have a running.NET BackgroundService. The pod is in good health. Memory and CPU appear okay. There are no glaring mistakes.

 

However, the line continues getting longer. Where is the issue, then?

We'll use a straightforward (DLCP) strategy to solve that problem in this post.

Detect → Locate → Correct → Prevent

Detect: identify that the system is falling behind.

Locate: find where processing capacity is being lost.

Correct: fix the bottleneck and protect the worker from slow dependencies.

Prevent: add the right limits, metrics, and safeguards so the same failure is caught before it becomes an incident.

We won’t start with the code and guess what went wrong. We’ll start with the symptom and work backwards through the system.

At 10:15 AM, everything looks normal.

The health endpoint returns 200. Then someone notices the queue.

Queue depth: 2,400

Thirty minutes later:

Queue depth: 11,800

Another fifteen minutes:

Queue depth: 52,381

The BackgroundService is still running. So why aren’t the messages being processed?

This is where these incidents get interesting.

A background worker can be perfectly alive as a process and still be completely useless as a message processor.

Start with four questions: Detect. Locate. Correct. Prevent.

That’s the path we’ll follow.

Detect

The first mistake is looking at the wrong signal. For a normal API, we might start with:

HTTP 5xx
CPU
Memory
Pod status
Request latency

Those are useful. But a message processor has another metric that matters more:

Is the queue actually moving? Suppose we see:

Queue depth:             52,381
Messages processed/sec:  18
Oldest message age:      24 minutes
Worker pods:             4
Pod status:              Running
CPU:                     12%
Memory:                  41%

Now the problem is obvious. The application is alive. The system isn’t making enough progress. That’s an important distinction. A process can be healthy while the business operation it performs is unhealthy.

For a background worker, I want to know at least:

  • Queue depth, Oldest message age, Messages processed per second, Processing duration, In-flight messages, Failure rate, Retry count, Last successful processing time

The queue depth tells us there is a problem.

The oldest message tells us how long that problem has existed.

Processing rate tells us whether we’re catching up or falling further behind.

Those three metrics alone can tell a very different story.

Locate

Now we know the worker isn’t keeping up. The next question is:

Where is the time going? Let’s start with the architecture.

The worker itself is simple. A simplified implementation might look like this:

public sealed class OrderWorker(
    IMessageQueue queue,
    IOrderProcessor processor) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            var message = await queue.ReceiveAsync(stoppingToken);
            await processor.ProcessAsync(message, stoppingToken);
        }
    }
}

The processor does two things:

public async Task ProcessAsync(
  OrderMessage message, 
  CancellationToken cancellationToken)
{
    await _paymentClient.ChargeAsync(message.OrderId, cancellationToken);
    await _orderRepository.MarkAsPaidAsync(message.OrderId, cancellationToken);
}

Nothing obviously wrong. So we follow the request.

The payment call normally takes:

200 ms

During the incident:

30 sec
45 sec
60 sec
90 sec

Now we have something. The worker isn’t spending its time processing orders. It’s spending its time waiting for the payment service.

Locate the Bottleneck

This is where concurrency matters. Concurrency simply means doing multiple operations at the same time instead of waiting for one to finish before starting the next one.

Suppose the worker has 10 messages in flight, meaning it can process up to 10 messages at the same time.

On a normal day, the payment API responds in around 200 ms.

If all 10 slots process messages concurrently:

Concurrency:       10
Processing time:   200 ms

All 10 messages take roughly 200 ms to complete:

 10 messages
───────────── = 50 messages/second
   200 ms

So the worker can theoretically process around 50 messages per second, assuming the queue, database, and other dependencies can keep up.

If those 10 messages were processed sequentially, the calculation would be different:

10 messages × 200 ms = 2,000 ms = 2 seconds

That would give us:

10 messages ÷ 2 seconds = 5 messages/second

That’s why concurrency matters. We’re not waiting for one message to finish before starting the next one.

Then something changes.

The payment service starts having problems. Requests that normally take around 200 ms now take as long as 60 seconds. Those same 10 concurrent slots now look like this:

Worker
│
├── Order 101 → Payment API → waiting
├── Order 102 → Payment API → waiting
├── Order 103 → Payment API → waiting
├── Order 104 → Payment API → waiting
├── ...
└── Order 110 → Payment API → waiting

All 10 slots are occupied for roughly 60 seconds. The throughput becomes:

Concurrency:       10
Processing time:   60 seconds

  10 messages
──────────────
  60 seconds

= 0.17 messages/second

So we went from roughly:

Normal:    ~50 messages/sec
Incident:  ~0.17 messages/sec

That’s a massive drop.

Why Scaling Made It Worse

The obvious response to a growing queue is:

Add more pods. So we go from:

2 pods to 10 pods

Now we potentially have:

10 pods × 20 concurrent operations = 200 in-flight requests

That sounds better. But where are those requests going?

The same payment API.


 Suppose the payment service can safely handle 50 concurrent requests. We just sent it 200. Now the payment service gets evem slower. Slower responses keep worker slots occupied longer. More messages accumulate. The queue grows again.


 

This is an important lesson in distributed systems:

Scaling one component does not necessarily increase the capacity of the system.

Correct

Now that we know where the problem is, we can fix it. The first change is to stop allowing downstream slowness to consume unlimited worker capacity.

1. Bound the concurrency

Instead of letting the consumer process as much work as possible, define a limit.

For example:

var options = new ParallelOptions
{
    MaxDegreeOfParallelism = 20,
    CancellationToken = stoppingToken
};

await Parallel.ForEachAsync(messages, options, ProcessMessageAsync);

Now the worker has an explicit concurrency boundary. But 20 isn't a magic number. It should come from the system.

You need to consider:

2. Put a timeout around slow dependencies

A worker shouldn’t wait forever for a downstream service.

For an HTTP client:

services.AddHttpClient<IPaymentClient, PaymentClient>(client =>
{
    client.BaseAddress = new Uri(configuration["PaymentService:BaseUrl"]!);
    client.Timeout = TimeSpan.FromSeconds(10);
});

Now a request that doesn’t complete within the allowed time releases the worker slot.

But a timeout creates another question: What happens after the timeout?

That’s where retry policy comes in.

3. Don’t retry blindly

Suppose the payment service returns a temporary 503. A retry can make sense. Suppose the message contains an invalid order ID. A retry won’t help.

So failures need classification.


For temporary failures, use controlled retries.

For example:

private static TimeSpan GetRetryDelay(int attempt)
{
    var seconds = Math.Pow(2, attempt);
    return TimeSpan.FromSeconds(seconds);
}

Which gives:

Attempt 1 → 2 sec
Attempt 2 → 4 sec
Attempt 3 → 8 sec
Attempt 4 → 16 sec

In production, add jitter so multiple workers don’t retry at exactly the same time.

When the dependency is struggling, the worker should reduce pressure, not increase it.

4. Stop calling a dependency that is already down

Retries aren’t enough when the dependency is completely unavailable. Imagine thousands of messages doing this:

                              Request
                                 ↓
                              Timeout
                                 ↓
                              Retry
                                 ↓
                              Timeout
                                 ↓
                              Retry
                                 ↓
                              Timeout

The worker is wasting capacity on a dependency that isn’t responding.

A circuit breaker changes that.


Instead of allowing every worker to keep discovering that the payment service is down, the system temporarily stops sending requests.

That gives the dependency room to recover.

5. Deal with messages that cannot succeed

Not every message deserves infinite retries.

Imagine:

{
    "orderId": null,
    "amount": "INVALID"
}

Retrying this message 100 times won’t fix it. After the retry limit:

The main queue keeps moving.

The failed message gets a separate path for investigation.

6. Assume messages can be delivered twice

There is another problem that appears in real message systems.

Suppose:

                        Receive message
                              ↓
                        Charge payment
                              ↓
                        Payment succeeds
                              ↓
                        Worker crashes before ACK

The queue doesn’t know the payment succeeded. It may deliver the message again.

Message 123
   │
   ├── Attempt 1 → Payment succeeds
   │
   └── Attempt 2 → Payment succeeds again

Now you’ve potentially charged the customer twice. Message processing should therefore be idempotent.

For example:

public async Task ProcessAsync(
  OrderMessage message, 
  CancellationToken cancellationToken)
{
    if (await _processedMessages.ExistsAsync(message.MessageId, 
                                             cancellationToken))
        return;

    await _paymentClient.ChargeAsync(message.OrderId, cancellationToken);
    await _orderRepository.MarkAsPaidAsync(message.OrderId, cancellationToken);
    await _processedMessages.AddAsync(message.MessageId, cancellationToken);
}

And the database should enforce uniqueness:

CREATE UNIQUE INDEX IX_ProcessedMessages_MessageId
ON ProcessedMessages(MessageId);

The corrected architecture

The worker now has boundaries around the things that can hurt it.

 


Failure
   │
   ├── Temporary → Retry
   │
   ├── Permanent → DLQ
   │
   └── Repeated → DLQ

Now it has clearer boundaries.

Prevent

Fixing the incident is only half the job. The next question is:

How do we know this is happening before customers notice it?

This is where monitoring changes. A generic health check might say:

Pod: Running
Health: OK

That’s not enough. Now we know, for a message processor, monitor progress. A useful dashboard might would be:


 Now we can answer something much more useful than:

Is the pod alive?

We can answer:

Is the system making progress?

The Architecture We Actually Want

A resilient message processor isn’t just:

Queue → BackgroundService → Database

It is closer to:


                 ┌──────────────────────────────┐
                 │        Observability         │
                 │                              │
                 │ Queue depth                  │
                 │ Processing rate              │
                 │ Latency                      │
                 │ Failure rate                 │
                 │ Retry rate                   │
                 │ Oldest message               │
                 │ Last successful processing   │
                 └──────────────────────────────┘

Every part has a job.

  • The queue absorbs bursts.

  • The worker controls concurrency.

  • Timeouts prevent indefinite waits.

  • Retries handle temporary failures.

  • Circuit breakers protect unhealthy dependencies.

  • Idempotency protects against duplicate delivery.

  • The DLQ prevents poison messages from blocking the system.

  • Metrics tell us whether the system is actually moving.

The Architectural Lesson

The interesting part of this problem is that there was no single broken line of code.

  • The worker was doing what it was designed to do.

  • The payment service was doing what it could.

  • Kubernetes was reporting the truth: the pods were running.

  • And the queue was doing its job too.

  • The failure appeared between those components.

That is where distributed-system problems usually live.

A BackgroundService is just a loop:

while (!stoppingToken.IsCancellationRequested)
{
    // Do some work
}

The hard part is everything around that loop.

  • How much work can it take?

  • What happens when a dependency slows down?

  • How long can it wait?

  • How many times should it retry?

  • What happens when the same message arrives twice?

  • What happens to a message that can never succeed?

And most importantly:

How do we know the worker is making progress?

A running worker is not necessarily a healthy worker.

A healthy pod is not necessarily a healthy message-processing system.

The metric that matters is not whether the loop is alive.

It’s whether the queue is moving.

HostForLIFE is Best Option for ASP.NET Core 10.0 Hosting in Europe

Frankly speaking, HostForLIFE is best option to host your ASP.NET Core 10.0 Hosting in Europe. You just need to spend €2.97/month to host your site with them and you can install the latest ASP.NET Core 10.0 via their Plesk control panel. We would highly recommend them as your ASP.NET Core 9.0 Hosting in Europe.

http://hostforlifeasp.net/European-ASPNET-Core-2-Hosting
Read More

Core ASP.NET 11 Static SSR: Evaluating HTML Payload Size and Performance

Leave a Comment

Building online apps that load quickly and function effectively on a variety of devices has always required server-side rendering. Blazor has historically offered a number of rendering alternatives in ASP.NET Core, such as WebAssembly-based rendering and interactive server rendering. Static Server-Side Rendering, often known as static SSR, is a more straightforward method in which the server creates the HTML and transmits it to the browser without creating an interactive Blazor circuit for that particular site.



That distinction may be significant.

Sending ready-to-use HTML can lessen the amount of work the browser must do if a page merely has to show content and doesn't involve client-side interaction. Additionally, it can lessen the amount of runtime infrastructure and application-specific JavaScript needed to make that page interactive.

Instead of seeing static SSR as merely another rendering option, it is worthwhile to consider it from a performance standpoint in light of the upcoming enhancements in ASP.NET Core 11.

This article describes the operation of static SSR, how to assess its effectiveness, and how to compare HTML payloads without drawing unwarranted conclusions about the outcomes. 

What Is Static SSR?

Static SSR means that the server renders a component into HTML and returns that HTML as part of the HTTP response.

The basic flow looks like this:

Browser
   |
   | HTTP Request
   v
ASP.NET Core Server
   |
   | Render component
   v
Generated HTML
   |
   | HTTP Response
   v
Browser displays HTML

There is no requirement for the page to become interactive after rendering.

For example, a simple Razor component can contain:

@page "/products"

<h1>Products</h1>

<ul>
    @foreach (var product in Products)
    {
        <li>
            @product.Name - @product.Price.ToString("C")
        </li>
    }
</ul>

@code {
    private readonly List<Product> Products =
    [
        new("Laptop", 85000),
        new("Monitor", 18000),
        new("Keyboard", 2500)
    ];

    private record Product(string Name, decimal Price);
}
Razor C#

The server renders the component and returns HTML that the browser can display directly.

This is different from an application where the browser first downloads a client-side runtime and then performs additional rendering work.

Static SSR vs Interactive Rendering

The most important question is not whether static SSR is faster in every situation. It is whether it is the right rendering mode for a particular page.

AreaStatic SSRInteractive ServerWebAssembly
Initial HTMLServer-generatedServer-generatedClient-generated after startup
Browser runtimeMinimalRequires interactive infrastructureRequires WebAssembly runtime
InteractivityNo by defaultYesYes
Server connectionNot required for static renderingRequired for interactive circuitNot required after download
Initial payloadPrimarily HTML and required assetsHTML plus interactive infrastructureHTML plus client application/runtime
Best fitContent-focused pagesInteractive applicationsRich client-side applications

The table should not be interpreted as a universal performance ranking.

A page that contains complex server-side processing may still have a high response time even if the browser receives static HTML. Similarly, a highly interactive application may gain little from making every page static.

The rendering strategy should match the workload.

Creating a Static SSR Page

A minimal Blazor Web App can be configured with static rendering.

For example, the application can register Razor components:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRazorComponents();

var app = builder.Build();

app.UseStaticFiles();
app.UseAntiforgery();

app.MapRazorComponents<App>();

app.Run();
C#

The exact service and endpoint configuration can vary depending on the application and rendering modes being used.

A component can then be rendered without enabling an interactive render mode.

@page "/dashboard"

<h1>Dashboard</h1>

<p>Server-rendered dashboard content.</p>
Razor C#

The important idea is that the page does not automatically require an interactive client connection simply to display its content.

Why HTML Payload Size Matters

Startup performance is not determined by server response time alone.

The browser also needs to receive the response, parse the HTML, download required resources, construct the DOM, and render the page.

A simplified model is:

Request
   |
   v
Server Processing
   |
   v
HTML Response
   |
   v
Network Transfer
   |
   v
HTML Parsing
   |
   v
DOM Construction
   |
   v
Visual Rendering

A smaller response can reduce network transfer work, especially on slower connections.

However, HTML size is only one part of the overall page-load equation. Images, CSS, JavaScript, fonts, caching, compression, and server processing can all contribute to the final experience.

Measuring Response Size

The first useful experiment is to measure the actual HTTP response.

For example, you can use curl:

curl -o /dev/null -s -w \
"HTTP: %{http_code}\nSize: %{size_download} bytes\nTime: %{time_total}s\n" \
https://localhost:5001/products
Bash

For local development, the URL and port will depend on your ASP.NET Core configuration.

This gives you a basic measurement of:

  • HTTP status

  • Downloaded response size

  • Total request time

For more detailed analysis, browser developer tools can show request size, transferred size, response timing, and other network information.

Measuring Server Response Time

ASP.NET Core applications can expose timing information through logging and diagnostics.

For a simple test, keep the endpoint logic stable and compare the same page under different rendering configurations.

For example:

app.MapGet("/benchmark", async () =>
{
    await Task.Delay(1);

    return Results.Ok(new
    {
        Message = "Benchmark response"
    });
});
C#

The example above is intentionally simple. In a real benchmark, the server should perform the actual work performed by the application.

If the page loads data from a database, use representative database access.

If the page performs expensive calculations, include those calculations.

Otherwise, the benchmark measures an artificial scenario rather than the application users actually experience.

Measuring Static SSR With Browser Developer Tools

The browser's Network tab is one of the easiest places to start.

Open the application and inspect the document request.

Look at:

  1. Request URL

  2. Status code

  3. Transferred size

  4. Resource size

  5. Waiting time

  6. Content download time

  7. Number of additional requests

The distinction between transferred size and resource size is useful.

Compression can make the amount transferred over the network smaller than the uncompressed HTML document.

For example:

HTML resource size:      85 KB
Transferred over HTTP:   19 KB

Those numbers represent different things.

When comparing payloads, record both when possible.

Testing With Compression Enabled

ASP.NET Core supports response compression, which can significantly affect network transfer size.

A compression configuration can look like this:

builder.Services.AddResponseCompression(options =>
{
    options.EnableForHttps = true;
});
C#

Then enable it in the middleware pipeline:

var app = builder.Build();

app.UseResponseCompression();

app.UseStaticFiles();

app.UseAntiforgery();

app.MapRazorComponents<App>();

app.Run();
C#

The exact compression behavior depends on the request headers and server configuration.

This is why a benchmark should clearly state whether compression is enabled.

Comparing one application with compression enabled against another without compression produces misleading payload results.

Designing a Fair Benchmark

A useful benchmark should change one major variable at a time.

For example:

VariableTest ATest B
ApplicationSameSame
DataSameSame
ServerSameSame
DatabaseSameSame
NetworkSameSame
CompressionSameSame
BrowserSameSame
Rendering modeStatic SSRAlternative mode

This gives you a controlled comparison.

Also run the test multiple times.

The first request can behave differently because of application startup, JIT compilation, database connections, caches, and other environmental factors.

What Should You Measure?

For a practical static SSR experiment, collect several metrics.

Server-Side Metrics

Measure:

  • Request processing time

  • Response size

  • Server CPU usage

  • Server memory usage

  • Request throughput

Browser and Network Metrics

Measure:

  • HTML transferred size

  • HTML resource size

  • Document request time

  • Number of requests

  • DOM content loaded

  • Largest Contentful Paint where applicable

You do not need every metric for every project. Start with the measurements that answer the question you are trying to investigate.

A Simple Test Matrix

A useful experiment can compare three scenarios:

Scenario A
Static SSR + Compression

Scenario B
Static SSR + No Compression

Scenario C
Interactive Rendering

Run each scenario against the same page and data.

For example:

ScenarioResponse TimeHTML SizeTransferred SizeAdditional Requests
Static SSR + CompressionMeasureMeasureMeasureMeasure
Static SSRMeasureMeasureMeasureMeasure
Interactive RenderingMeasureMeasureMeasureMeasure

The values should come from actual test runs rather than assumptions.

This is especially important when publishing benchmark results because server hardware, application complexity, network conditions, and browser behavior can change the outcome.

Production Considerations

Static SSR is particularly attractive for pages where the user primarily needs information.

Examples include:

  • Product details

  • Documentation

  • Public profiles

  • News or article pages

  • Search result pages

  • Marketing content

  • Read-only dashboards

An interactive page, however, may still need interactive rendering.

For example, a shopping cart with quantity controls does not become a better user experience simply because its initial HTML is static.

A practical application can also combine approaches.

A page can render most content statically while using interactive rendering only for components that actually require user interaction.

This avoids treating the entire application as either completely static or completely interactive.

Common Mistakes

Measuring Only HTML Size

A smaller HTML document does not automatically mean a faster application.

Look at the complete request and rendering path.

Ignoring Compression

Compressed and uncompressed payload sizes are different measurements.

Always document the compression configuration.

Comparing Different Data Sets

A page containing 10 records and another containing 10,000 records cannot provide a meaningful payload comparison.

Use the same dataset.

Benchmarking Development Builds

Development tooling can affect performance.

Use a production-like Release configuration for meaningful measurements.

Treating One Device or Network as Universal

A result from a fast local connection does not necessarily represent users on mobile networks.

Consider testing under realistic network conditions when user-facing performance is the goal.

Troubleshooting

If static SSR appears slower than expected, investigate the server before blaming the rendering mechanism.

Check:

  1. Database query duration.

  2. Number of database queries.

  3. Server-side component processing.

  4. Large object creation.

  5. Expensive serialization.

  6. HTML size.

  7. Compression configuration.

  8. Additional CSS and JavaScript requests.

  9. Cache behavior.

  10. Network latency.

For example, a static page that performs several slow database queries can still have a poor Time to First Byte even though the browser receives ordinary HTML.

The rendering model cannot eliminate expensive server-side work.

Advantages

  • Sends ready-to-display HTML from the server.

  • Can reduce the amount of client-side runtime work for static content.

  • Works well for content-focused pages.

  • Can provide a simple request-response model.

  • Allows developers to avoid unnecessary interactivity.

  • Can be combined with interactive rendering where needed.

Disadvantages

  • Does not provide client-side interactivity by itself.

  • Server-side rendering can still be slow if application logic is expensive.

  • Large datasets can produce large HTML responses.

  • Performance depends on server, network, browser, and application behavior.

  • Pages requiring rich interaction may need a different rendering mode.

Best Practices

Render Only What the User Needs

Do not generate thousands of unnecessary HTML elements simply because the server can.

Pagination, filtering, and virtualization can still matter for server-rendered applications.

Keep Server Work Efficient

Static SSR moves rendering work to the server. It does not remove that work.

Optimize database queries, avoid unnecessary service calls, and keep component initialization focused.

Measure Compressed and Uncompressed Size

Both numbers provide useful information.

Use Appropriate Rendering Modes

Use static SSR for content that does not need immediate interactivity and interactive rendering where users actually need it.

Benchmark Production-Like Builds

Use realistic data, Release configuration, representative hardware, and realistic network conditions.

Conclusion

ASP.NET Core static SSR provides a straightforward way to deliver server-generated HTML without automatically turning every page into an interactive client application.

Its performance should be evaluated using real measurements rather than broad assumptions.

The most useful experiment is a controlled comparison where the application, data, server, device, network conditions, and compression settings remain consistent while the rendering strategy changes.

Measure server response time, HTML size, transferred bytes, browser timing, and additional resource requests. Those measurements provide a much clearer picture than looking at any single metric.

Static SSR is not a universal replacement for interactive rendering. Its real value comes from using it where it fits: pages where users primarily need fast, server-generated content and do not require an interactive client runtime for every part of the experience.

Windows Hosting Recommendation

HostForLIFEASP.NET receives Spotlight standing advantage award for providing recommended, cheap and fast ecommerce Hosting including the latest Magento. From the leading technology company, Microsoft. All the servers are equipped with the newest Windows Server 2022 R2, SQL Server 2022, ASP.NET Core 10.0 , ASP.NET MVC, Silverlight 5, WebMatrix and Visual Studio Lightswitch. Security and performance are at the core of their Magento hosting operations to confirm every website and/or application hosted on their servers is highly secured and performs at optimum level. mutually of the European ASP.NET hosting suppliers, HostForLIFE guarantees 99.9% uptime and fast loading speed. From €3.49/month , HostForLIFE provides you with unlimited disk space, unlimited domains, unlimited bandwidth,etc, for your website hosting needs.
 
https://hostforlifeasp.net/
Read More

Comparing NativeAOT CLI Tools with JIT-Based.NET Applications

Leave a Comment

The performance profile of long-running web services differs from that of command-line programs. In order to give the.NET runtime time to optimize execution after initialization, a web API may remain active for hours or days. A CLI tool may take some input, generate output, operate for a few seconds, and then terminate.



This increases the significance of startup time and application footprint. Instead of using the conventional JIT-based execution approach, NativeAOT offers a technique to compile an application into native code in advance.

But NativeAOT is not automatically faster for every workload.

A useful engineering question is:

When does NativeAOT provide a measurable advantage over a conventional JIT-based .NET CLI application?

The answer depends on startup cost, workload duration, binary size, memory usage, throughput, dependencies, reflection requirements, and deployment environment.

This article presents a practical methodology for benchmarking NativeAOT CLI tools against conventional .NET applications without confusing startup improvements with overall application performance.

What NativeAOT Changes

A conventional .NET application typically follows a runtime model similar to:

Application
    |
    v
.NET runtime
    |
    v
JIT compilation
    |
    v
Machine code
    |
    v
Execution

NativeAOT changes the model:

Application
    |
    v
AOT compilation
    |
    v
Native executable
    |
    v
Operating system
    |
    v
Execution

The important difference is when code generation happens.

With JIT-based execution, portions of code can be compiled at runtime.

With NativeAOT, application code is compiled ahead of time.

This can reduce startup work, but it also introduces additional build-time constraints.

Why CLI Applications Are Interesting

Consider a CLI command that executes for 300 milliseconds.

The total execution time may look like:

Startup      100 ms
Processing   150 ms
Shutdown      50 ms
-------------------
Total        300 ms

If startup is reduced significantly, the total runtime can change substantially.

Now consider a server application:

Startup       500 ms
Processing    12 hours

The startup difference is almost irrelevant to the application's lifetime.

This is why NativeAOT benchmarking is particularly interesting for:

  • Developer CLI tools

  • Build utilities

  • Code generators

  • File-processing tools

  • Deployment utilities

  • Short-lived automation jobs

  • Containerized command-line workloads

JIT-Based and NativeAOT Builds Are Different Deployment Models

A fair benchmark should compare equivalent applications.

For example:

CLI source
   |
   +---- Standard .NET build
   |
   +---- NativeAOT publish

Both versions should:

  • Perform the same work

  • Process the same input

  • Produce the same output

  • Use the same algorithm

  • Run on the same machine

  • Use the same operating-system environment

Do not compare a highly optimized NativeAOT implementation with a different JIT implementation.

The benchmark is about the deployment model, not application design.

Create a Representative CLI Workload

Start with a simple application that performs measurable work.

For example, a JSON-processing CLI might:

  1. Read a file.

  2. Deserialize records.

  3. Transform the data.

  4. Calculate summary information.

  5. Serialize the result.

  6. Exit.

A simplified implementation could look like:

using System.Text.Json;

var inputPath = args.Length > 0
    ? args[0]
    : "input.json";

await using var stream =
    File.OpenRead(inputPath);

var records =
    await JsonSerializer.DeserializeAsync<List<Record>>(stream)
    ?? [];

var total = records.Sum(x => x.Amount);

Console.WriteLine(
    $"Records: {records.Count}");

Console.WriteLine(
    $"Total: {total:N2}");

public sealed class Record
{
    public int Id { get; set; }
    public decimal Amount { get; set; }
}
C#

The application should do enough work to make the benchmark meaningful.

Publish the Standard Version

Build the conventional application using the normal release configuration:

dotnet publish \
    -c Release \
    -r win-x64 \
    --self-contained true
Bash

The exact runtime identifier should match the machine used for testing.

Self-contained and framework-dependent deployments should not be mixed in the same comparison because they represent different deployment characteristics.

Publish the NativeAOT Version

NativeAOT can be enabled in the project configuration.

A simplified project configuration looks like:

<PropertyGroup>
    <PublishAot>true</PublishAot>
</PropertyGroup>
XML

Then publish for the target runtime:

dotnet publish \
    -c Release \
    -r win-x64
Bash

The resulting output is intended to run as a native executable.

The important point is that the NativeAOT build should use the same source code and release configuration as the baseline unless the benchmark explicitly investigates a different configuration.

NativeAOT Has Compatibility Constraints

NativeAOT is not simply a switch that makes every .NET application faster.

Some applications depend heavily on runtime behaviors such as:

  • Reflection

  • Dynamic code generation

  • Runtime type discovery

  • Certain serialization patterns

  • Dynamic loading

  • Libraries that are not AOT-compatible

Modern .NET libraries increasingly provide AOT-friendly patterns, but an application should be tested rather than assumed to be compatible.

This matters when designing a benchmark.

If the JIT version works but the NativeAOT version requires substantial architectural changes, you are no longer measuring only the runtime deployment model.

Measure Startup Separately

One of the biggest mistakes in CLI benchmarking is measuring only total execution time.

Measure startup independently.

A useful conceptual model is:

Total Time
    =
Startup
+
Application Work
+
Shutdown

For a short-running CLI, startup can represent a large percentage of total execution time.

A benchmark should therefore capture:

Process launch
     |
     v
Application ready
     |
     v
Work begins
     |
     v
Application exits

Operating-system process timing tools can be used for coarse end-to-end measurements.

For more detailed investigation, application-level instrumentation can record when meaningful application work begins.

Warm Runs Can Mislead CLI Benchmarks

A JIT-based application can behave differently between its first and subsequent executions.

For example:

Run 1
Startup + JIT + application work

Run 2
Startup + cached operating-system state + application work

If you benchmark only warm runs, you may hide part of the startup cost that matters to users running a CLI command once.

Run both:

Cold-start scenario
Warm-start scenario

and clearly report which one each measurement represents.

Benchmark Repeated CLI Invocations

A useful test is to run each executable repeatedly.

For example:

Standard .NET
Run 1
Run 2
Run 3
...
Run 30

NativeAOT
Run 1
Run 2
Run 3
...
Run 30

Then calculate:

  • Median

  • p95

  • Minimum

  • Maximum

  • Standard deviation

For short-lived processes, the median is often more useful than a single measurement.

A single execution can be affected by:

  • File-system state

  • Antivirus scanning

  • CPU scheduling

  • Background processes

  • Operating-system caching

  • Thermal conditions

Measure Memory Usage

Startup time is only one part of the comparison.

Measure process memory as well.

Useful metrics include:

Working set
Private memory
Peak memory
Managed allocations

Do not confuse process working set with managed heap size.

A native executable can have different memory behavior even when the managed allocation profile is similar.

For a CLI tool running hundreds or thousands of times in automation, small differences can become operationally important.

Measure Binary and Deployment Size

NativeAOT can change the shape of the deployment artifact.

Measure:

Executable size
Total publish directory size
Number of files
Required runtime components

For example:

MetricJIT-BasedNativeAOT
Executable sizeMeasureMeasure
Publish directoryMeasureMeasure
Startup timeMeasureMeasure
Peak memoryMeasureMeasure
Processing timeMeasureMeasure
Total execution timeMeasureMeasure

Do not assume that NativeAOT always produces a smaller artifact.

The correct result depends on application dependencies and publishing configuration.

Separate Startup From Throughput

Suppose the benchmark produces these measurements:

JIT
Startup:     80 ms
Processing: 120 ms
Total:       200 ms

NativeAOT
Startup:     25 ms
Processing: 125 ms
Total:       150 ms

NativeAOT improves total execution time primarily because startup is lower.

That is different from saying:

NativeAOT makes the algorithm 50% faster.

It does not.

The processing phase actually became slightly slower in this hypothetical example.

This distinction is important when presenting benchmark results.

Measure CPU Usage

NativeAOT can change CPU behavior, but the direction is workload-dependent.

Measure CPU usage during:

Startup
Processing
Shutdown

For example:

CPU Time
   |
   +-- Process startup
   +-- JIT/AOT-related execution
   +-- Application processing

For short-running processes, percentage-based CPU measurements can be misleading because the process may terminate before sampling tools capture enough information.

CPU time is often more useful when available.

Avoid Overfitting to a Microbenchmark

A CLI tool that calculates a small mathematical expression may not represent a real application.

For example:

var result = 10 * 20;
C#

is not an interesting NativeAOT workload.

Instead, use workloads that represent actual CLI behavior:

JSON processing
File processing
Parsing
Code generation
Database interaction
Archive processing
Batch transformation

The benchmark should answer a real deployment question.

A Useful Benchmark Matrix

A practical test matrix might look like this:

WorkloadInput SizeRunsJITNativeAOT
JSON parsing100 KB30MeasureMeasure
JSON parsing10 MB30MeasureMeasure
File processing10 MB30MeasureMeasure
File processing500 MB10MeasureMeasure
Code generationSmall30MeasureMeasure
Code generationLarge30MeasureMeasure

The exact workloads should reflect the application's purpose.

Use BenchmarkDotNet Carefully

BenchmarkDotNet is useful for controlled .NET performance experiments, but process-startup benchmarking requires care.

A microbenchmark typically keeps one process alive and invokes methods repeatedly.

That is useful for measuring:

Method execution
Allocations
CPU performance

It is not automatically equivalent to:

Launching CLI process
Starting runtime
Loading application
Performing work
Exiting process

For CLI startup benchmarking, measure the executable as a process.

For algorithm benchmarking, BenchmarkDotNet can be used separately.

A strong performance study can therefore have two layers:

Layer 1
Process-level benchmark
        |
        +-- Startup
        +-- Total runtime
        +-- Memory
        +-- Deployment size

Layer 2
Method-level benchmark
        |
        +-- CPU
        +-- Allocations
        +-- Throughput

This prevents startup and algorithm performance from being mixed together.

Control the Benchmark Environment

Run both versions under the same conditions.

Keep consistent:

  • Operating system

  • CPU

  • Memory

  • Runtime identifier

  • Input files

  • Working directory

  • Environment variables

  • Power profile

  • Background workload

  • Security scanning configuration

  • File-system location

For serious performance work, run the benchmark multiple times and document the environment.

Otherwise, the result may be impossible to reproduce.

Common Mistakes

Measuring Only One Execution

One process launch is not enough to establish a reliable performance difference.

Comparing Debug Builds

Always compare appropriate release builds.

Mixing Framework-Dependent and Self-Contained Builds

These deployment models have different characteristics.

Ignoring Cold Starts

For short-lived CLI tools, cold-start behavior can be the most important metric.

Measuring Only Total Runtime

Separate startup and application processing.

Assuming NativeAOT Is Always Faster

NativeAOT can improve startup while producing little or no improvement in steady-state processing.

Ignoring Compatibility

A library that depends on runtime code generation may require changes before it works correctly with NativeAOT.

Reporting Unsupported Precision

If two executions differ by a few milliseconds, do not turn that into a broad performance claim without repeated measurements.

Troubleshooting NativeAOT Build Problems

If the NativeAOT publish fails, first inspect the build output for compatibility diagnostics.

Common areas to investigate include:

Reflection
Dynamic code
Serialization
Assembly loading
Third-party dependencies
Source generators
Runtime type discovery

A useful troubleshooting approach is to isolate the problematic dependency.

For example:

Application
   |
   +-- Library A
   +-- Library B
   +-- Library C

Temporarily test whether the issue is associated with one library or one runtime feature.

Do not immediately rewrite the application.

First identify which part of the application is incompatible with the AOT compilation model.

When NativeAOT Is Worth Considering

NativeAOT deserves serious consideration when:

  • Startup time is important.

  • Applications run for short periods.

  • Deployment as a native executable is valuable.

  • Runtime installation should be minimized.

  • CLI tools are launched frequently.

  • Container startup matters.

  • The application's dependencies support AOT effectively.

It may be less compelling when:

  • The application runs continuously.

  • Startup represents an insignificant portion of total runtime.

  • The application depends heavily on dynamic runtime behavior.

  • The workload is dominated by external I/O.

  • AOT compatibility requires substantial architectural changes.

The decision should come from measurements rather than the technology label.

A Practical Benchmark Report

A useful report should contain more than a table of execution times.

Record:

Application version
.NET SDK version
OS
CPU
Memory
Runtime identifier
Publish configuration
Input dataset
Number of iterations
Cold/warm methodology
JIT configuration
NativeAOT configuration

Then report:

MetricJIT-Based .NETNativeAOTDifference
Cold startupMeasureMeasureCalculate
Warm startupMeasureMeasureCalculate
Total runtimeMeasureMeasureCalculate
Processing timeMeasureMeasureCalculate
Peak memoryMeasureMeasureCalculate
Executable sizeMeasureMeasureCalculate
Publish sizeMeasureMeasureCalculate

This format makes the benchmark useful for an engineering decision.

Frequently Asked Questions

Is NativeAOT the same as ReadyToRun?

No. They are different compilation and deployment approaches. ReadyToRun can reduce some JIT work while retaining the .NET runtime model. NativeAOT produces a native executable using ahead-of-time compilation.

Does NativeAOT always improve startup time?

It can provide significant startup benefits for appropriate applications, but the actual improvement depends on the application, operating system, dependencies, and deployment configuration. Measure it.

Does NativeAOT always reduce memory usage?

No. Memory behavior depends on the application and its dependencies. Measure working set and peak memory rather than assuming a result.

Is NativeAOT useful for ASP.NET Core?

It can be useful for certain server workloads, particularly where startup and deployment characteristics matter, but the value proposition is different from a short-lived CLI tool.

Should I use BenchmarkDotNet for CLI startup?

Use process-level measurements for actual CLI startup and lifecycle behavior. BenchmarkDotNet is more appropriate for controlled method-level performance experiments.

Does NativeAOT eliminate the .NET runtime?

The deployment model produces a native executable rather than requiring the traditional JIT-based runtime execution model. However, the application still uses the relevant .NET runtime libraries and native runtime components incorporated into the application.

Conclusion

NativeAOT can be a strong option for .NET CLI applications, but its value should be demonstrated with measurements rather than assumed from the fact that the application is compiled ahead of time.

For short-lived command-line tools, startup time can represent a significant part of total execution time, making NativeAOT particularly interesting. For long-running workloads, steady-state processing performance may matter much more than startup.

A reliable comparison should therefore measure cold and warm startup, total execution time, processing time, memory usage, CPU consumption, and deployment size using the same workload and environment. Separating process-level startup measurements from method-level performance benchmarks also prevents misleading conclusions.

The best outcome of a NativeAOT benchmark is not simply proving that one deployment model is faster. It is identifying which part of the application's lifecycle benefits, by how much, and whether that improvement justifies the compatibility and build-time trade-offs of NativeAOT.

Windows Hosting Recommendation

HostForLIFE.eu receives Spotlight standing advantage award for providing recommended, cheap and fast ecommerce Hosting including the latest Magento. From the leading technology company, Microsoft. All the servers are equipped with the newest Windows Server 2022 R2, SQL Server 2022, ASP.NET Core 10.0 , ASP.NET MVC, Silverlight 5, WebMatrix and Visual Studio Lightswitch. Security and performance are at the core of their Magento hosting operations to confirm every website and/or application hosted on their servers is highly secured and performs at optimum level. mutually of the European ASP.NET hosting suppliers, HostForLIFE guarantees 99.9% uptime and fast loading speed. From €3.49/month , HostForLIFE provides you with unlimited disk space, unlimited domains, unlimited bandwidth,etc, for your website hosting needs.
 
https://hostforlifeasp.net/
Read More
Previous PostOlder Posts Home