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



European ASP.NET Core 10.0 Hosting - HostForLIFE :: Using ASP.NET Core to Create a Production-Ready Image Analysis API

clock August 5, 2026 11:00 by author Peter

Images are being accepted as input by more and more modern online apps.

Users can provide a product photo and ask for structured characteristics, upload a snapshot and expect OCR text, or submit an interface capture and inquire about its contents.

The backend can initially seem straightforward:

  • Accept a picture.
  • Forward it to a vision model.
  • Give back the outcome that was produced.

This design works for a prototype, but it becomes unreliable when real users upload large files, corrupted images, unsupported formats, or several requests at the same time.

A production system also needs to handle:

  • Secure file validation
  • Private storage
  • Long-running tasks
  • External provider timeouts
  • Duplicate processing
  • Task recovery
  • Structured error states
  • Frontend status polling
  • Usage limits
  • Privacy and data retention

In this article, we will build a provider-independent image analysis API with ASP.NET Core.

The API will:

  • Validate uploaded images on the server
  • Generate safe internal file names
  • Store images outside the public web directory
  • Create asynchronous processing tasks
  • Queue tasks with Channel<T>
  • Run analysis with BackgroundService
  • Separate application code from the vision provider
  • Expose a task-status endpoint
  • Return completed and failed states clearly

Products such as Describe Image demonstrate the practical value of turning images into descriptions, OCR text, alt text, prompts, product information, and reusable notes.

The implementation below is an independent architecture example and does not describe the internal implementation of any specific product.

1. Project Architecture

The API should return quickly after an image upload is accepted.

The actual vision-model request should run outside the HTTP request lifecycle.

The workflow will look like this:
Client
    ↓
POST /api/image-analysis
    ↓
Upload validation
    ↓
Private file storage
    ↓
Task creation
    ↓
Background queue
    ↓
ImageAnalysisWorker
    ↓
Vision provider
    ↓
Task result storage
    ↓
GET /api/image-analysis/{taskId}
    ↓
Client receives the status and result


This architecture prevents an HTTP connection from remaining open while an external model processes an image.

It also makes retries, monitoring, and task recovery easier to implement.

2. Create the ASP.NET Core Project

Create a new ASP.NET Core Web API project:
dotnet new webapi -n ImageAnalysisApi
cd ImageAnalysisApi


The sample uses standard ASP.NET Core services and does not require a specific AI provider.

3. Define the Analysis Modes

Different visual tasks require different instructions and output formats. OCR should not use the same prompt as alt-text generation, product analysis, or a detailed image description.

Create a Models folder and add AnalysisMode.cs:
namespace ImageAnalysisApi.Models;

public enum AnalysisMode
{
    DetailedDescription,
    Ocr,
    AltText,
    ProductAnalysis
}

Using an enum prevents clients from submitting arbitrary mode names and gives the backend a stable list of supported operations.

4. Define the Task States
A Boolean property such as IsComplete is not enough for a long-running workflow. The client needs to know whether a task is waiting, actively processing, completed, or failed.

Create AnalysisTaskStatus.cs:
namespace ImageAnalysisApi.Models;

public enum AnalysisTaskStatus
{
    Queued,
    Processing,
    Completed,
    Failed
}


Now create ImageAnalysisTask.cs:
namespace ImageAnalysisApi.Models;

public sealed class ImageAnalysisTask
{
    public required Guid Id { get; init; }

    public required string StoredFilePath { get; init; }

    public required string ContentType { get; init; }

    public required AnalysisMode Mode { get; init; }

    public AnalysisTaskStatus Status { get; set; }

    public string? Result { get; set; }

    public string? ErrorCode { get; set; }

    public string? ErrorMessage { get; set; }

    public DateTimeOffset CreatedAt { get; init; }

    public DateTimeOffset UpdatedAt { get; set; }
}


Detailed states improve:

  • Frontend progress handling
  • Retry logic
  • Operational monitoring
  • Customer support
  • Failure diagnosis
  • Usage accounting

A production application should store these records in a durable database.

5. Create the Task Store
The task store separates persistence logic from controllers and background workers.

Create IAnalysisTaskStore.cs:
using ImageAnalysisApi.Models;

namespace ImageAnalysisApi.Services;

public interface IAnalysisTaskStore
{
    Task CreateAsync(
        ImageAnalysisTask task,
        CancellationToken cancellationToken);

    Task<ImageAnalysisTask?> GetAsync(
        Guid taskId,
        CancellationToken cancellationToken);

    Task UpdateAsync(
        ImageAnalysisTask task,
        CancellationToken cancellationToken);
}


For this tutorial, use a thread-safe in-memory implementation.

Create InMemoryAnalysisTaskStore.cs:
using System.Collections.Concurrent;
using ImageAnalysisApi.Models;

namespace ImageAnalysisApi.Services;

public sealed class InMemoryAnalysisTaskStore
    : IAnalysisTaskStore
{
    private readonly ConcurrentDictionary<Guid, ImageAnalysisTask>
        _tasks = new();

    public Task CreateAsync(
        ImageAnalysisTask task,
        CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();

        if (!_tasks.TryAdd(task.Id, task))
        {
            throw new InvalidOperationException(
                $"Task {task.Id} already exists.");
        }

        return Task.CompletedTask;
    }

    public Task<ImageAnalysisTask?> GetAsync(
        Guid taskId,
        CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();

        _tasks.TryGetValue(taskId, out var task);

        return Task.FromResult(task);
    }

    public Task UpdateAsync(
        ImageAnalysisTask task,
        CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();

        _tasks[task.Id] = task;

        return Task.CompletedTask;
    }
}

Because this implementation stores everything in memory, all tasks disappear when the application restarts. That is acceptable for a tutorial, but not for a production deployment.

A real implementation can use:

  • SQL Server
  • PostgreSQL
  • MySQL
  • Redis
  • Azure Cosmos DB
  • Another durable task store

6. Validate Uploaded Files
Never trust the original file name, extension, or browser-provided content type by itself.

The server should verify:

  • A file was supplied
  • The file is not empty
  • The file does not exceed the size limit
  • The declared content type is supported
  • The file signature matches the expected image format

Create IImageUploadValidator.cs:
namespace ImageAnalysisApi.Services;

public interface IImageUploadValidator
{
    Task ValidateAsync(
        IFormFile file,
        CancellationToken cancellationToken);
}


Create ImageUploadValidator.cs:
namespace ImageAnalysisApi.Services;

public sealed class ImageUploadValidator
    : IImageUploadValidator
{
    private const long MaxFileSize =
        10 * 1024 * 1024;

    private static readonly HashSet<string>
        AllowedContentTypes =
        new(StringComparer.OrdinalIgnoreCase)
    {
        "image/jpeg",
        "image/png",
        "image/webp"
    };

    public async Task ValidateAsync(
        IFormFile file,
        CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(file);

        if (file.Length == 0)
        {
            throw new InvalidDataException(
                "The uploaded file is empty.");
        }

        if (file.Length > MaxFileSize)
        {
            throw new InvalidDataException(
                "The uploaded file exceeds the 10 MB limit.");
        }

        if (!AllowedContentTypes.Contains(file.ContentType))
        {
            throw new InvalidDataException(
                "Only JPEG, PNG, and WebP images are supported.");
        }

        await using var stream =
            file.OpenReadStream();

        var header = new byte[12];

        var bytesRead = await stream.ReadAsync(
            header.AsMemory(0, header.Length),
            cancellationToken);

        if (!MatchesSupportedSignature(
            header.AsSpan(0, bytesRead)))
        {
            throw new InvalidDataException(
                "The file signature does not match a supported image.");
        }
    }

    private static bool MatchesSupportedSignature(
        ReadOnlySpan<byte> header)
    {
        return IsJpeg(header)
            || IsPng(header)
            || IsWebP(header);
    }

    private static bool IsJpeg(
        ReadOnlySpan<byte> header)
    {
        return header.Length >= 3
            && header[0] == 0xFF
            && header[1] == 0xD8
            && header[2] == 0xFF;
    }

    private static bool IsPng(
        ReadOnlySpan<byte> header)
    {
        byte[] signature =
        {
            0x89, 0x50, 0x4E, 0x47,
            0x0D, 0x0A, 0x1A, 0x0A
        };

        return header.Length >= signature.Length
            && header[..signature.Length]
                .SequenceEqual(signature);
    }

    private static bool IsWebP(
        ReadOnlySpan<byte> header)
    {
        byte[] riff =
        {
            0x52, 0x49, 0x46, 0x46
        };

        byte[] webp =
        {
            0x57, 0x45, 0x42, 0x50
        };

        return header.Length >= 12
            && header[..4].SequenceEqual(riff)
            && header.Slice(8, 4).SequenceEqual(webp);
    }
}


File-signature validation prevents obvious extension and MIME-type spoofing, but it is not a complete security solution.

A production system should also consider:

  • Decoding the image with a trusted image library
  • Rejecting excessive image dimensions
  • Limiting the total pixel count
  • Removing unnecessary metadata
  • Running malware scanning
  • Applying per-user rate limits
  • Restricting concurrent tasks

7. Store Files with Safe Names
Do not use the original upload name as the physical storage name.
The original name may contain:

  • Unsafe characters
  • Misleading extensions
  • Path-related values
  • Personally identifiable information
  • Duplicate names

Create IImageStorage.cs:
namespace ImageAnalysisApi.Services;

public interface IImageStorage
{
    Task<string> SaveAsync(
        IFormFile file,
        CancellationToken cancellationToken);

    Task<Stream> OpenReadAsync(
        string storedPath,
        CancellationToken cancellationToken);
}


Create LocalImageStorage.cs:
namespace ImageAnalysisApi.Services;

public sealed class LocalImageStorage
    : IImageStorage
{
    private readonly string _uploadDirectory;

    public LocalImageStorage(
        IWebHostEnvironment environment)
    {
        _uploadDirectory = Path.Combine(
            environment.ContentRootPath,
            "App_Data",
            "uploads");

        Directory.CreateDirectory(
            _uploadDirectory);
    }

    public async Task<string> SaveAsync(
        IFormFile file,
        CancellationToken cancellationToken)
    {
        var extension =
            GetSafeExtension(file.ContentType);

        var generatedName =
            $"{Guid.NewGuid():N}{extension}";

        var fullPath = Path.Combine(
            _uploadDirectory,
            generatedName);

        await using var destination =
            new FileStream(
                fullPath,
                FileMode.CreateNew,
                FileAccess.Write,
                FileShare.None,
                bufferSize: 81920,
                useAsync: true);

        await file.CopyToAsync(
            destination,
            cancellationToken);

        return fullPath;
    }

    public Task<Stream> OpenReadAsync(
        string storedPath,
        CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();

        Stream stream = new FileStream(
            storedPath,
            FileMode.Open,
            FileAccess.Read,
            FileShare.Read,
            bufferSize: 81920,
            useAsync: true);

        return Task.FromResult(stream);
    }

    private static string GetSafeExtension(
        string contentType)
    {
        return contentType.ToLowerInvariant() switch
        {
            "image/jpeg" => ".jpg",
            "image/png" => ".png",
            "image/webp" => ".webp",
            _ => throw new InvalidDataException(
                "Unsupported image type.")
        };
    }
}


The upload directory is placed under App_Data instead of wwwroot. This prevents uploaded files from automatically becoming public web assets. For distributed deployments, private object storage is usually more appropriate than local disk.

Possible services include:

  • Azure Blob Storage
  • Amazon S3
  • Google Cloud Storage
  • Cloudflare R2
  • MinIO

8. Create an Asynchronous Task Queue
Channel<T> can be used to create a bounded in-process queue.

A bounded queue provides backpressure and prevents unlimited memory growth.

Create IAnalysisTaskQueue.cs:
namespace ImageAnalysisApi.Services;

public interface IAnalysisTaskQueue
{
    ValueTask QueueAsync(
        Guid taskId,
        CancellationToken cancellationToken);

    ValueTask<Guid> DequeueAsync(
        CancellationToken cancellationToken);
}


Create AnalysisTaskQueue.cs:
using System.Threading.Channels;

namespace ImageAnalysisApi.Services;

public sealed class AnalysisTaskQueue
    : IAnalysisTaskQueue
{
    private readonly Channel<Guid> _channel;

    public AnalysisTaskQueue()
    {
        var options =
            new BoundedChannelOptions(100)
            {
                FullMode =
                    BoundedChannelFullMode.Wait,
                SingleReader = true,
                SingleWriter = false
            };

        _channel =
            Channel.CreateBounded<Guid>(options);
    }

    public ValueTask QueueAsync(
        Guid taskId,
        CancellationToken cancellationToken)
    {
        return _channel.Writer.WriteAsync(
            taskId,
            cancellationToken);
    }

    public ValueTask<Guid> DequeueAsync(
        CancellationToken cancellationToken)
    {
        return _channel.Reader.ReadAsync(
            cancellationToken);
    }
}


An in-process queue is appropriate for:

  • Tutorials
  • Small projects
  • Single-instance applications
  • Local development

For multiple application instances, use a durable message broker such as:

  • Azure Service Bus
  • RabbitMQ
  • Amazon SQS
  • Apache Kafka
  • Google Cloud Pub/Sub

An in-process queue loses pending items when the application restarts.

9. Abstract the Vision Provider
Vendor-specific model calls should not be placed directly inside the controller.
Create an interface so the provider can be changed without rewriting the rest of the application.

Create IVisionAnalysisProvider.cs:
using ImageAnalysisApi.Models;

namespace ImageAnalysisApi.Services;

public interface IVisionAnalysisProvider
{
    Task<string> AnalyzeAsync(
        Stream image,
        string contentType,
        AnalysisMode mode,
        CancellationToken cancellationToken);
}


For this article, create a simulated provider.

Create DemoVisionAnalysisProvider.cs:
using ImageAnalysisApi.Models;

namespace ImageAnalysisApi.Services;

public sealed class DemoVisionAnalysisProvider
    : IVisionAnalysisProvider
{
    public async Task<string> AnalyzeAsync(
        Stream image,
        string contentType,
        AnalysisMode mode,
        CancellationToken cancellationToken)
    {
        await Task.Delay(
            TimeSpan.FromSeconds(2),
            cancellationToken);

        return mode switch
        {
            AnalysisMode.Ocr =>
                "Demo OCR result: visible text would appear here.",

            AnalysisMode.AltText =>
                "A concise alt-text description of the uploaded image.",

            AnalysisMode.ProductAnalysis =>
                "A structured description of visible product attributes.",

            _ =>
                "A detailed description of the uploaded image."
        };
    }
}


A real implementation may call:

  • A cloud computer-vision API
  • A multimodal language model
  • A self-hosted vision model
  • An internal Python inference service
  • A separate microservice

The controller and worker do not need to know which provider is used.

They only depend on IVisionAnalysisProvider.

10. Process Tasks with BackgroundService

The background worker will:

  • Dequeue a task ID
  • Load the task record
  • Confirm that it is still queued
  • Mark it as processing
  • Open the stored image
  • Call the vision provider
  • Store the result
  • Mark the task as completed or failed

Create ImageAnalysisWorker.cs:
using ImageAnalysisApi.Models;

namespace ImageAnalysisApi.Services;

public sealed class ImageAnalysisWorker
    : BackgroundService
{
    private readonly IAnalysisTaskQueue _queue;
    private readonly IAnalysisTaskStore _taskStore;
    private readonly IImageStorage _storage;
    private readonly IVisionAnalysisProvider _provider;
    private readonly ILogger<ImageAnalysisWorker> _logger;

    public ImageAnalysisWorker(
        IAnalysisTaskQueue queue,
        IAnalysisTaskStore taskStore,
        IImageStorage storage,
        IVisionAnalysisProvider provider,
        ILogger<ImageAnalysisWorker> logger)
    {
        _queue = queue;
        _taskStore = taskStore;
        _storage = storage;
        _provider = provider;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(
        CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            var taskId =
                await _queue.DequeueAsync(
                    stoppingToken);

            try
            {
                await ProcessTaskAsync(
                    taskId,
                    stoppingToken);
            }
            catch (OperationCanceledException)
                when (stoppingToken.IsCancellationRequested)
            {
                break;
            }
            catch (Exception exception)
            {
                _logger.LogError(
                    exception,
                    "Unhandled error for task {TaskId}.",
                    taskId);
            }
        }
    }

    private async Task ProcessTaskAsync(
        Guid taskId,
        CancellationToken cancellationToken)
    {
        var task = await _taskStore.GetAsync(
            taskId,
            cancellationToken);

        if (task is null)
        {
            _logger.LogWarning(
                "Task {TaskId} was not found.",
                taskId);

            return;
        }

        if (task.Status != AnalysisTaskStatus.Queued)
        {
            _logger.LogInformation(
                "Task {TaskId} has status {Status} and will not be processed again.",
                task.Id,
                task.Status);

            return;
        }

        task.Status =
            AnalysisTaskStatus.Processing;

        task.UpdatedAt =
            DateTimeOffset.UtcNow;

        await _taskStore.UpdateAsync(
            task,
            cancellationToken);

        try
        {
            await using var imageStream =
                await _storage.OpenReadAsync(
                    task.StoredFilePath,
                    cancellationToken);

            var result =
                await _provider.AnalyzeAsync(
                    imageStream,
                    task.ContentType,
                    task.Mode,
                    cancellationToken);

            if (string.IsNullOrWhiteSpace(result))
            {
                throw new InvalidOperationException(
                    "The vision provider returned an empty result.");
            }

            task.Result = result;

            task.Status =
                AnalysisTaskStatus.Completed;

            task.ErrorCode = null;
            task.ErrorMessage = null;
        }
        catch (OperationCanceledException)
            when (cancellationToken.IsCancellationRequested)
        {
            throw;
        }
        catch (Exception exception)
        {
            task.Status =
                AnalysisTaskStatus.Failed;

            task.ErrorCode =
                "ANALYSIS_FAILED";

            task.ErrorMessage =
                "The image could not be analyzed.";

            _logger.LogError(
                exception,
                "Image analysis failed for task {TaskId}.",
                task.Id);
        }
        finally
        {
            task.UpdatedAt =
                DateTimeOffset.UtcNow;

            await _taskStore.UpdateAsync(
                task,
                CancellationToken.None);
        }
    }
}


The worker checks that the task is still queued before processing it.

This reduces accidental duplicate work.
A distributed production system should claim tasks through:

  • An atomic database update
  • A row lock
  • A distributed lock
  • A unique stage-execution record

Checking the task status in memory is not enough for strict distributed idempotency.

11. Create the API Controller

The POST endpoint will:

  • Validate the image
  • Store the file
  • Create a task
  • Add the task to the queue
  • Return HTTP 202 Accepted

The GET endpoint will return the current status and result.
Create ImageAnalysisController.cs:
using ImageAnalysisApi.Models;
using ImageAnalysisApi.Services;
using Microsoft.AspNetCore.Mvc;

namespace ImageAnalysisApi.Controllers;

[ApiController]
[Route("api/image-analysis")]
public sealed class ImageAnalysisController
    : ControllerBase
{
    private readonly IImageUploadValidator _validator;
    private readonly IImageStorage _storage;
    private readonly IAnalysisTaskStore _taskStore;
    private readonly IAnalysisTaskQueue _taskQueue;

    public ImageAnalysisController(
        IImageUploadValidator validator,
        IImageStorage storage,
        IAnalysisTaskStore taskStore,
        IAnalysisTaskQueue taskQueue)
    {
        _validator = validator;
        _storage = storage;
        _taskStore = taskStore;
        _taskQueue = taskQueue;
    }

    [HttpPost]
    [Consumes("multipart/form-data")]
    [ProducesResponseType(
        StatusCodes.Status202Accepted)]
    [ProducesResponseType(
        StatusCodes.Status400BadRequest)]
    public async Task<IActionResult> CreateAsync(
        [FromForm] IFormFile image,
        [FromForm] AnalysisMode mode,
        CancellationToken cancellationToken)
    {
        try
        {
            await _validator.ValidateAsync(
                image,
                cancellationToken);
        }
        catch (InvalidDataException exception)
        {
            return BadRequest(new
            {
                error = exception.Message
            });
        }

        var storedPath =
            await _storage.SaveAsync(
                image,
                cancellationToken);

        var now =
            DateTimeOffset.UtcNow;

        var task =
            new ImageAnalysisTask
            {
                Id = Guid.NewGuid(),
                StoredFilePath = storedPath,
                ContentType = image.ContentType,
                Mode = mode,
                Status =
                    AnalysisTaskStatus.Queued,
                CreatedAt = now,
                UpdatedAt = now
            };

        await _taskStore.CreateAsync(
            task,
            cancellationToken);

        await _taskQueue.QueueAsync(
            task.Id,
            cancellationToken);

        return AcceptedAtAction(
            nameof(GetAsync),
            new
            {
                taskId = task.Id
            },
            new
            {
                taskId = task.Id,
                status =
                    task.Status.ToString(),
                statusUrl =
                    Url.ActionLink(
                        nameof(GetAsync),
                        values: new
                        {
                            taskId = task.Id
                        })
            });
    }

    [HttpGet("{taskId:guid}")]
    [ProducesResponseType(
        StatusCodes.Status200OK)]
    [ProducesResponseType(
        StatusCodes.Status404NotFound)]
    public async Task<IActionResult> GetAsync(
        Guid taskId,
        CancellationToken cancellationToken)
    {
        var task =
            await _taskStore.GetAsync(
                taskId,
                cancellationToken);

        if (task is null)
        {
            return NotFound(new
            {
                error = "Task not found."
            });
        }

        return Ok(new
        {
            taskId = task.Id,
            mode =
                task.Mode.ToString(),
            status =
                task.Status.ToString(),
            result =
                task.Status ==
                AnalysisTaskStatus.Completed
                    ? task.Result
                    : null,
            errorCode =
                task.ErrorCode,
            errorMessage =
                task.Status ==
                AnalysisTaskStatus.Failed
                    ? task.ErrorMessage
                    : null,
            createdAt =
                task.CreatedAt,
            updatedAt =
                task.UpdatedAt
        });
    }
}


Returning HTTP 202 Accepted tells the client that the request has been accepted, but processing is not complete.

The client can poll the status endpoint until the task becomes Completed or Failed.

12. Register the Services

Update Program.cs:
using ImageAnalysisApi.Services;

var builder =
    WebApplication.CreateBuilder(args);

builder.Services.AddControllers();

builder.Services.AddSingleton<
    IAnalysisTaskStore,
    InMemoryAnalysisTaskStore>();

builder.Services.AddSingleton<
    IAnalysisTaskQueue,
    AnalysisTaskQueue>();

builder.Services.AddSingleton<
    IImageUploadValidator,
    ImageUploadValidator>();

builder.Services.AddSingleton<
    IImageStorage,
    LocalImageStorage>();

builder.Services.AddSingleton<
    IVisionAnalysisProvider,
    DemoVisionAnalysisProvider>();

builder.Services.AddHostedService<
    ImageAnalysisWorker>();

var app =
    builder.Build();

app.UseHttpsRedirection();

app.MapControllers();

app.Run();


The task store, queue, validator, storage service, and demo provider are singletons in this simplified example. If a future implementation uses Entity Framework Core, resolve scoped services inside a dependency-injection scope created by the worker.

13. Test the API

Start the application:
dotnet run

Submit an image with cURL:
curl -X POST "https://localhost:7001/api/image-analysis" \
-F "[email protected];type=image/png" \
-F "mode=DetailedDescription"

A successful response resembles:
{
  "taskId": "4af8f71b-f93f-44c2-9358-472e8f52497d",
  "status": "Queued",
  "statusUrl": "https://localhost:7001/api/image-analysis/4af8f71b-f93f-44c2-9358-472e8f52497d"
}


Poll the status endpoint:
curl "https://localhost:7001/api/image-analysis/4af8f71b-f93f-44c2-9358-472e8f52497d"


While processing:
{
  "taskId": "4af8f71b-f93f-44c2-9358-472e8f52497d",
  "mode": "DetailedDescription",
  "status": "Processing",
  "result": null,
  "errorCode": null,
  "errorMessage": null,
  "createdAt": "2026-08-03T07:00:00+00:00",
  "updatedAt": "2026-08-03T07:00:01+00:00"
}


After completion:
{
  "taskId": "4af8f71b-f93f-44c2-9358-472e8f52497d",
  "mode": "DetailedDescription",
  "status": "Completed",
  "result": "A detailed description of the uploaded image.",
  "errorCode": null,
  "errorMessage": null,
  "createdAt": "2026-08-03T07:00:00+00:00",
  "updatedAt": "2026-08-03T07:00:03+00:00"
}


14. Return Structured Results
A plain string is enough for a basic prototype, but structured output is easier to validate and consume.

For example:
namespace ImageAnalysisApi.Models;

public sealed record ImageAnalysisResult(
    string Summary,
    IReadOnlyList<string> VisibleText,
    IReadOnlyList<DetectedObject> Objects,
    IReadOnlyList<string> Warnings);

public sealed record DetectedObject(
    string Name,
    double? Confidence);


The provider interface can then return ImageAnalysisResult instead of string:
using ImageAnalysisApi.Models;

namespace ImageAnalysisApi.Services;

public interface IStructuredVisionAnalysisProvider
{
    Task<ImageAnalysisResult> AnalyzeAsync(
        Stream image,
        string contentType,
        AnalysisMode mode,
        CancellationToken cancellationToken);
}


Structured output is useful because it allows the frontend to display:

  • A short summary
  • Extracted OCR text
  • Detected objects
  • Confidence values
  • Warnings
  • Unknown or unverified information

Do not assume that an external model will always return valid JSON.

Validate provider output before saving it or returning it to the client.

15. Separate Visible Facts from Inference
Image-analysis models may generate plausible assumptions that are not directly supported by the image.
For product analysis, the output should distinguish between:

  • Visible attributes
  • Possible interpretations
  • Unknown information

A structured model might look like this:
public sealed record ProductImageAnalysis(
    IReadOnlyDictionary<string, string> VisibleAttributes,
    IReadOnlyDictionary<string, string> PossibleAttributes,
    IReadOnlyList<string> UnknownAttributes);

For example:
{
  "visibleAttributes": {
    "color": "black",
    "closure": "zipper",
    "surface": "matte"
  },
  "possibleAttributes": {
    "material": "possibly synthetic fabric"
  },
  "unknownAttributes": [
    "exact dimensions",
    "weight",
    "manufacturer",
    "water resistance"
  ]
}


This prevents the application from presenting guesses as verified specifications.

16. Add Explicit Provider Timeouts

An unavailable external provider should not block the worker indefinitely.

A provider implementation can use HttpClient with an explicit timeout:
builder.Services.AddHttpClient(
    "VisionProvider",
    client =>
    {
        client.Timeout =
            TimeSpan.FromSeconds(60);
    });


The provider should also respect the CancellationToken passed by the worker. Timeouts should be classified separately from permanent failures so that transient errors can be retried safely.

17. Retry Only Transient Errors
Not every failure should be retried.

Possible failures include:

  • Invalid input
  • Unsupported image format
  • Provider timeout
  • Rate limiting
  • Invalid provider output
  • Moderation rejection
  • Storage failure
  • Network interruption

A retry policy might behave like this:

  • Invalid input: do not retry
  • Unsupported format: do not retry
  • Timeout: retry with exponential backoff
  • Rate limit: retry after the provider delay
  • Invalid output: retry once with a repair request
  • Storage failure: retry storage without repeating analysis
  • Moderation rejection: do not retry automatically

Repeating the complete pipeline can create duplicate provider charges.

Each expensive stage should be independently recoverable where possible.

18. Delete Temporary Files

The sample keeps uploaded files on disk.
A production application needs a retention policy.

Possible approaches include:

  • Delete the original after successful analysis
  • Keep it for a limited number of hours
  • Keep it only for authenticated users
  • Allow users to delete it manually
  • Store results longer than source images
  • Run scheduled cleanup jobs

Do not rely only on a privacy-policy statement.

Retention rules should be enforced by code.

19. Add Authentication and Rate Limiting

Image analysis can be expensive.

Without limits, an anonymous user may upload many large files and create excessive provider costs.

A production API should consider:

  • User authentication
  • API keys
  • Per-user daily limits
  • Per-IP limits
  • Maximum concurrent tasks
  • Maximum storage usage
  • Mode-specific credit costs
  • Request deduplication

ASP.NET Core rate limiting can protect the upload endpoint before a task reaches the background queue.

20. Improve Observability

Do not log raw images, full OCR results, or private storage URLs by default.
Instead, log safe operational information:

  • Task ID
  • User ID when appropriate
  • Analysis mode
  • File size
  • Image dimensions
  • Processing duration
  • Provider latency
  • Retry count
  • Failure code
  • Queue waiting time
  • Final task status

This provides enough information for monitoring without turning application logs into a database of sensitive image contents.

21. Buffered Uploads Versus Streaming

IFormFile uses buffered upload handling and is convenient for smaller files.
For very large images, high concurrency, or video uploads, consider multipart streaming.
Streaming prevents the application from buffering excessive content in memory or temporary disk space.

The correct choice depends on:

  1. Expected file size
  2. Number of simultaneous uploads
  3. Available memory
  4. Temporary storage performance
  5. Deployment architecture
  6. Whether uploads are sent directly to object storage

22. Why This Architecture Is Maintainable
The design separates responsibilities:

  • The controller handles HTTP behavior
  • The validator handles upload rules
  • The storage service handles files
  • The queue controls asynchronous work
  • The worker coordinates processing
  • The provider handles model integration
  • The task store tracks state

This separation makes the system easier to test and modify.
You can replace the demo provider without changing the API contract.
You can move from local disk to object storage without rewriting the worker.
You can replace the in-memory task store with a database without changing the controller.
You can introduce a durable queue without changing the vision provider.

Conclusion

Building a reliable image analysis API requires more than sending an image to an AI model. A production-oriented system must validate uploads, generate safe file names, control resource usage, store files securely, process long-running tasks asynchronously, expose clear task states, validate model output, and recover from failures without duplicating expensive work. ASP.NET Core provides the core components required for this architecture, including dependency injection, controllers, hosted background services, asynchronous file operations, and structured configuration.

The provider-independent design can be extended for:

  • OCR
  • Alt text
  • Screenshot understanding
  • Product image analysis
  • Document-image extraction
  • Prompt generation
  • Visual question answering
  • Chart interpretation
  • Interface analysis

The most important principle is to treat the vision model as one component inside a larger application boundary.

The model generates an answer, but the surrounding application determines whether that answer is secure, traceable, recoverable, and useful.

Summary

This post showed how to use asynchronous processing, secure file validation, private storage, background workers, task queues, and a pluggable vision provider architecture to create an ASP.NET Core provider-independent image analysis API. The application becomes more scalable, durable, and maintainable for real-world deployments by integrating production-oriented principles including retry methods, rate limitation, observability, and organized task states.

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 ASP.NET Core's Outbox Pattern for Dependable Event Publishing

clock August 3, 2026 10:51 by author Peter

Current apps frequently have to broadcast an event to a message broker and update a database. An e-commerce program may, for instance, broadcast an OrderCreated event to RabbitMQ, Azure Service Bus, or Kafka after saving an order to SQL Server.

Making sure both operations are successful together is the difficult part. The event is never received by downstream systems if the database transaction commits but event publishing is unsuccessful. On the other hand, customers receive an event for nonexistent data if the event is published but the database transaction fails. By storing events in the same database transaction as the business data and publishing them asynchronously thereafter, the Outbox Pattern resolves this issue.

In this article, you'll build a production-ready Outbox implementation in ASP.NET Core, understand how it works, and learn best practices for reliable event publishing.

    Note: This article focuses on architecture and implementation. Performance depends on workload, database design, polling intervals, and messaging infrastructure.

What Is the Outbox Pattern?

Instead of publishing an event directly after saving data, the application stores the event in an Outbox table within the same database transaction.

A background service later reads pending events and publishes them to the message broker.
Client Request
      │
      ▼
Business Logic
      │
      ▼
Database Transaction
           │
 ┌────┴─────────┐
 ▼                           ▼
Business Data  Outbox Event
      │
      ▼
 Transaction Commit
      │
      ▼
Background Publisher
      │
      ▼
Message Broker

This guarantees that if the business transaction succeeds, the event is never lost.

Why Use the Outbox Pattern?
Without the Outbox Pattern:

  • Database commit succeeds.
  • Message broker becomes unavailable.
  • Event is lost.
  • Downstream services never process the operation.

With the Outbox Pattern:

  • Business data and event are committed together.
  • Event publishing can be retried safely.
  • Temporary messaging failures don't lose events.

Create the Outbox Entity
Create an entity to store pending events.
public class OutboxMessage
{
    public Guid Id { get; set; }

    public string EventType { get; set; } = "";

    public string Payload { get; set; } = "";

    public DateTime CreatedAt { get; set; }

    public DateTime? ProcessedAt { get; set; }
}

Each row represents one event waiting to be published.

Configure EF Core
Register the Outbox table.
public class AppDbContext : DbContext
{
    public DbSet<Order> Orders => Set<Order>();

    public DbSet<OutboxMessage> Outbox =>
        Set<OutboxMessage>();

    public AppDbContext(
        DbContextOptions<AppDbContext> options)
        : base(options)
    {
    }
}


Run the migration to create the table.
dotnet ef migrations add AddOutbox

dotnet ef database update

Save Business Data and Event Together
Suppose an order is created.
public async Task CreateOrderAsync(Order order)
{
    _context.Orders.Add(order);

    var message = new OutboxMessage
    {
        Id = Guid.NewGuid(),
        EventType = "OrderCreated",
        Payload = JsonSerializer.Serialize(order),
        CreatedAt = DateTime.UtcNow
    };

    _context.Outbox.Add(message);

    await _context.SaveChangesAsync();
}

Both the order and the Outbox record are committed in a single database transaction.

Build the Background Publisher
Create a hosted service that processes pending events.
public class OutboxProcessor
    : BackgroundService
{
    private readonly IServiceProvider _provider;

    public OutboxProcessor(
        IServiceProvider provider)
    {
        _provider = provider;
    }

    protected override async Task ExecuteAsync(
        CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            using var scope =
                _provider.CreateScope();

            var db = scope.ServiceProvider
                .GetRequiredService<AppDbContext>();

            var events = await db.Outbox
                .Where(x => x.ProcessedAt == null)
                .ToListAsync(stoppingToken);

            foreach (var message in events)
            {
                // Publish to broker

                message.ProcessedAt =
                    DateTime.UtcNow;
            }

            await db.SaveChangesAsync(
                stoppingToken);

            await Task.Delay(
                TimeSpan.FromSeconds(5),
                stoppingToken);
        }
    }
}


The service periodically publishes pending events and marks them as processed.

Register the Hosted Service

builder.Services.AddHostedService<
    OutboxProcessor>();


The publisher starts automatically with the application.

Publishing to a Message Broker

The publishing logic depends on the messaging platform.
await publisher.PublishAsync(
    message.EventType,
    message.Payload);


The Outbox Pattern works with:

  • RabbitMQ
  • Azure Service Bus
  • Apache Kafka
  • Amazon SQS
  • NATS
  • Redis Streams

The persistence strategy remains the same regardless of the broker.

Handling Failures
If publishing fails:

  • Keep the Outbox row unchanged.
  • Retry during the next polling cycle.
  • Mark the event as processed only after successful publishing.

Avoid deleting events before confirming successful delivery.

Prevent Duplicate Processing
A consumer may receive the same event more than once if publishing succeeds but the application crashes before updating ProcessedAt.

Consumers should therefore be idempotent.

Example strategies include:

  • Event IDs
  • Message deduplication
  • Processed message table
  • Unique business keys

Idempotency is a critical part of any reliable messaging architecture.

End-to-End Workflow

A complete request follows these steps:

  • Client submits an order.
  • Order is saved.
  • Outbox event is stored.
  • Database transaction commits.
  • Background service reads pending events.
  • Event is published.
  • Event is marked as processed.
  • Consumers process the event.

This ensures reliable event delivery even during temporary messaging outages.

Outbox Pattern vs Direct Publishing

FeatureDirect PublishOutbox Pattern

Atomic database update

No

Yes

Reliable delivery

Limited

Yes

Retry support

Manual

Built-in

Event persistence

No

Yes

Temporary broker outage handling

Poor

Excellent

Suitable for microservices

Moderate

Excellent

Performance Evaluation Methodology

The research brief references reliability and scalability but does not include benchmark results. Instead of presenting unsupported figures, evaluate your implementation using the following methodology.

Test Environment
Keep these variables consistent:

  • .NET SDK version
  • Database engine
  • Message broker
  • Hardware
  • Network conditions
  • Polling interval

Test Scenarios

Compare:

  • Direct publishing
  • Outbox implementation
  • Broker outage
  • Database failure
  • High event throughput
  • Multiple publisher instances
  • Metrics to Measure

Collect:

  1. Events published per second
  2. Publish latency
  3. Failed publishes
  4. Retry count
  5. Queue backlog
  6. Database growth
  7. CPU utilization
  8. Memory usage

Useful Tools
Useful tools include:

  • BenchmarkDotNet (for application components)
  • dotnet-counters
  • dotnet-trace
  • SQL Server Query Store
  • RabbitMQ Management UI
  • Azure Service Bus metrics
  • Kafka monitoring tools

Validate behavior under production-like failure conditions rather than relying only on successful execution paths.

Best Practices

  • Store Outbox records in the same transaction as business data.
  • Keep event payloads immutable.
  • Include unique event identifiers.
  • Build idempotent consumers.
  • Monitor Outbox table growth.
  • Archive or remove processed events periodically.
  • Implement retry with exponential backoff if appropriate.

Log publishing failures with sufficient context.

Common Mistakes

MistakeImpact

Publishing before committing the database

Lost consistency

Deleting events before successful publish

Event loss

No retry mechanism

Failed events remain unpublished

Non-idempotent consumers

Duplicate processing

Never cleaning the Outbox table

Unbounded database growth

Long polling intervals

Increased event latency

Troubleshooting
Events Remain in the Outbox Table

Verify:

  • Background service is running.
  • Message broker is available.
  • Publishing logic is not throwing exceptions.
  • Database updates are being committed.

Duplicate Events
Review:

  • Consumer idempotency.
  • Event identifiers.
  • Retry logic.
  • Publisher crash recovery.

Outbox Table Grows Continuously
Check:

  • Cleanup strategy.
  • Processing failures.
  • Polling frequency.
  • Broker health.

Implement scheduled archival or deletion of successfully processed events.

FAQs
Why not publish directly after SaveChanges()?

If publishing fails after the database transaction succeeds, the event is permanently lost. The Outbox Pattern eliminates this inconsistency.

Does the Outbox Pattern guarantee exactly-once delivery?
No. It typically guarantees at-least-once delivery. Consumers should be designed to handle duplicate events safely.

Can the Outbox Pattern work with any message broker?
Yes. It is independent of the messaging platform and can be used with RabbitMQ, Azure Service Bus, Kafka, and other brokers.

How often should the background service poll the Outbox table?
The interval depends on latency requirements and workload. Shorter intervals reduce event latency but increase database activity.

Should processed Outbox records be deleted?
Yes. Archive or delete processed records periodically to prevent the Outbox table from growing indefinitely.

Conclusion
A tried-and-true method for dependable event publication in distributed systems is the Outbox Pattern. It removes the possibility of losing events as a result of transient communications failures by storing business data and integration events within the same database transaction. The Outbox Pattern offers a strong basis for event-driven ASP.NET Core applications and microservice architectures when combined with background processing, retries, idempotent consumers, and routine cleanup.

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



About HostForLIFE.eu

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

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


Month List

Tag cloud

Sign in