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 :: How to Include a Rate Restricting to APIs for ASP.NET Core

clock October 19, 2026 11:03 by author Peter

When a client submits too many queries in a short amount of time, an API may become sluggish or unavailable. A poorly designed application, an automated script, an unexpected surge in traffic, or a deliberate attempt to overwhelm the service can all cause this. Rate restriction aids in regulating the frequency of client access to an API. Developers may create rules that restrict requests based on time windows, concurrent requests, or other permitted criteria using ASP.NET Core's built-in rate limitation middleware.

Setting a request restriction is simply one aspect of the approach, though. When restrictions are exceeded, the policy must reflect the usage patterns of the API, identify clients effectively, provide helpful replies, and function well across many application instances.

This article describes how to apply rules to endpoints, return HTTP 429 replies, establish rate limitation in ASP.NET Core, and steer clear of typical implementation errors.

What Is Rate Limiting?
Rate limiting controls the number of requests allowed within a defined period or the number of requests that can execute concurrently. For example, an API might allow a client to make 100 requests per minute. If the client exceeds that limit, the API can reject additional requests until the applicable limit permits more traffic.

Rate limiting can help protect APIs from excessive usage, but it does not replace authentication, authorization, input validation, or network-level protection.

ASP.NET Core provides several built-in rate-limiting algorithms.

Algorithm

How it works

Typical use

Fixed window

Allows a configured number of requests during each time window

Simple request quotas

Sliding window

Distributes the limit across overlapping time segments

Smoother request limits

Token bucket

Refills tokens over time and allows controlled bursts

APIs that need limited burst capacity

Concurrency limiter

Limits the number of requests executing simultaneously

Expensive or resource-intensive operations

The first three algorithms limit request frequency over time. A concurrency limiter controls simultaneous operations rather than enforcing a requests-per-minute quota.

Choosing the right algorithm depends on the endpoint and the resources it consumes.

Add Rate Limiting Middleware

ASP.NET Core includes rate limiting middleware through the Microsoft.AspNetCore.RateLimiting namespace. In modern ASP.NET Core applications, you can configure it in Program.cs.

The following example uses a fixed-window policy:
using System.Threading.RateLimiting;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();

builder.Services.AddRateLimiter(options =>
{
    options.RejectionStatusCode =
        StatusCodes.Status429TooManyRequests;

    options.AddFixedWindowLimiter("api", limiter =>
    {
        limiter.PermitLimit = 100;
        limiter.Window = TimeSpan.FromMinutes(1);
        limiter.QueueLimit = 0;
        limiter.AutoReplenishment = true;
    });
});

var app = builder.Build();

app.UseRouting();

app.UseRateLimiter();

app.MapControllers();

app.Run();


This configuration creates a policy named api. It allows up to 100 requests per minute for each limiter partition. Requests that exceed the limit are rejected with HTTP 429 because the queue limit is zero.The example defines the policy but does not automatically apply it to every endpoint. You must attach the policy to the endpoints that should use it.

The middleware should run after routing when endpoint-specific rate-limiting policies are being applied. If authentication is part of the design, place authentication and authorization middleware in an order that allows the limiter to use the appropriate authenticated identity.

Apply a Rate-Limiting Policy to a Controller

For a controller-based API, use the [EnableRateLimiting] attribute to apply a named policy.

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;

[ApiController]
[Route("api/products")]
[EnableRateLimiting("api")]
public class ProductsController : ControllerBase
{
    [HttpGet]
    public IActionResult GetProducts()
    {
        return Ok(new[]
        {
            new { Id = 1, Name = "Keyboard" },
            new { Id = 2, Name = "Mouse" }
        });
    }
}

Requests to this controller now use the api rate-limiting policy.

You can also apply a policy to a specific endpoint instead of an entire controller. For example, a product listing endpoint might allow a higher request rate than an expensive report-generation endpoint.

For minimal APIs, apply the policy with RequireRateLimiting:

app.MapGet("/api/products", () =>
{
    return Results.Ok(new[]
    {
        new { Id = 1, Name = "Keyboard" },
        new { Id = 2, Name = "Mouse" }
    });
})
.RequireRateLimiting("api");

Use endpoint-level policies when different parts of the API have different performance or business requirements.

Choose the Right Rate-Limiting Algorithm
Fixed-window limiting

A fixed-window limiter counts requests within a fixed period. Once the window expires, the counter resets.
options.AddFixedWindowLimiter("fixed", limiter =>
{
    limiter.PermitLimit = 60;
    limiter.Window = TimeSpan.FromMinutes(1);
    limiter.QueueLimit = 0;
    limiter.AutoReplenishment = true;
});

This configuration allows 60 requests per minute per partition.

Fixed windows are straightforward to configure and understand. However, they can allow bursts around window boundaries. A client might send 60 requests immediately before a window ends and another 60 just after the next window begins.

For workloads where boundary bursts are acceptable, a fixed window can be sufficient.

Sliding-window limiting

A sliding-window limiter divides a larger time window into smaller segments. When a new segment begins, older request counts gradually stop contributing to the active window.

options.AddSlidingWindowLimiter("sliding", limiter =>
{
    limiter.PermitLimit = 60;
    limiter.Window = TimeSpan.FromMinutes(1);
    limiter.SegmentsPerWindow = 6;
    limiter.QueueLimit = 0;
    limiter.AutoReplenishment = true;
});


This policy uses six segments in a one-minute window. It smooths the transition between windows compared with a fixed-window limiter.

Sliding windows are useful when you want to limit request frequency without allowing large bursts at window boundaries. They do not guarantee perfectly even spacing between every request.

Token-bucket limiting

A token bucket starts with a configured number of tokens and replenishes tokens over time. Each accepted request consumes a token.
options.AddTokenBucketLimiter("token-bucket", limiter =>
{
    limiter.TokenLimit = 20;
    limiter.TokensPerPeriod = 10;
    limiter.ReplenishmentPeriod =
        TimeSpan.FromSeconds(30);
    limiter.QueueLimit = 0;
    limiter.AutoReplenishment = true;
});


This configuration permits a burst of up to 20 requests when the bucket is full, then replenishes 10 tokens every 30 seconds.

Token buckets are useful when an API should tolerate short bursts while controlling sustained traffic. The precise behavior depends on the token capacity, replenishment rate, and current token count.

Concurrency limiting
A concurrency limiter restricts how many requests can execute at the same time.
options.AddConcurrencyLimiter("expensive", limiter =>
{
    limiter.PermitLimit = 10;
    limiter.QueueLimit = 0;
});


This policy allows up to 10 concurrent requests per partition. It does not limit the total number of requests a client can make over a minute if those requests complete quickly.

Concurrency limiting is useful for expensive operations such as report generation, large exports, or resource-intensive queries. It can protect a service from having too many costly operations running simultaneously.

Partition Requests by Client

The earlier examples use a named policy, but a limiter's partitioning strategy determines which requests share the same limit.

A global limiter can apply a common limit to all requests. A partitioned limiter can maintain separate limits for different users, API keys, tenants, or other client identifiers.

For example, the following policy uses an authenticated user's identity when available and otherwise falls back to the remote IP address:
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.RateLimiting;

builder.Services.AddRateLimiter(options =>
{
    options.RejectionStatusCode =
        StatusCodes.Status429TooManyRequests;

    options.AddPolicy("per-client", context =>
    {
        var userId = context.User.Identity?.IsAuthenticated == true
            ? context.User.Identity.Name
            : null;

        var clientKey = !string.IsNullOrWhiteSpace(userId)
            ? $"user:{userId}"
            : $"ip:{context.Connection.RemoteIpAddress}";

        return RateLimitPartition.GetFixedWindowLimiter(
            clientKey,
            _ => new FixedWindowRateLimiterOptions
            {
                PermitLimit = 60,
                Window = TimeSpan.FromMinutes(1),
                QueueLimit = 0,
                AutoReplenishment = true
            });
    });
});

Apply this policy to the required endpoints:
app.MapGet("/api/orders", () =>
{
    return Results.Ok("Order list");
})
.RequireRateLimiting("per-client");


This is a starting point rather than a complete identity strategy. Identity.Name may be null or unsuitable for partitioning if the authentication configuration does not provide a stable unique value. In a production application, use a trusted, stable user identifier or a validated API-key identifier.

The partition key should also be normalized and bounded. Creating partitions from arbitrary attacker-controlled strings can consume memory as the number of distinct keys grows.

For anonymous clients, IP-based partitioning is only as reliable as the remote address presented to the application. When the application is behind a reverse proxy or load balancer, configure forwarded-header processing and trust only known proxies. Do not trust arbitrary client-supplied forwarding headers, because an attacker could manipulate the apparent IP address to bypass a limit.

Return a Useful HTTP 429 Response

When a client exceeds a rate limit, HTTP 429 Too Many Requests is the standard status code for communicating that the request was rejected because of rate limiting.

ASP.NET Core lets you customize the rejection response:
builder.Services.AddRateLimiter(options =>
{
    options.RejectionStatusCode =
        StatusCodes.Status429TooManyRequests;

    options.OnRejected = async (context, token) =>
    {
        context.HttpContext.Response.ContentType =
            "application/json";

        await context.HttpContext.Response.WriteAsJsonAsync(
            new
            {
                error = "rate_limit_exceeded",
                message =
                    "Too many requests. Please try again later."
            },
            cancellationToken: token);
    };
});

This produces a JSON response when the limiter rejects a request. The application can also include a suitable Retry-After header when it can calculate a reliable retry time for the selected policy. Do not promise a precise retry time unless the application can determine it accurately. For a token bucket or a partitioned policy, the correct retry interval may depend on the current state of the limiter.

Clients should treat HTTP 429 as a signal to slow down. They should avoid immediately retrying every rejected request, which can increase pressure on the service.

Understand In-Memory Limits in Multi-Instance Deployments
The built-in ASP.NET Core rate limiter maintains its limiter state in the application process. It is not automatically a shared, distributed rate limiter.
Suppose an application runs on three server instances behind a load balancer. Each instance has its own in-memory counter. If each instance allows 100 requests per minute for a given partition, a client distributed across all three instances may be able to make substantially more than 100 requests during that period.

This behavior is important when enforcing account quotas, paid API plans, or security-related limits.

For simple per-instance protection, the built-in limiter may be sufficient. For a strict limit shared across multiple instances, use a distributed rate-limiting solution or enforce the quota at a gateway or service that maintains shared state.

An API gateway can also provide an additional layer of protection before requests reach the application. However, gateway limits and application-level limits solve different problems and may be used together.

Configure Rate Limiting Alongside Authentication
A rate limiter should use an identity that matches the intended policy.

For public endpoints, limiting by IP address may be useful. For authenticated APIs, limiting by user or tenant can provide fairer quotas. For API-key-based services, a validated key identifier can be more appropriate than the source IP.

If a policy depends on HttpContext.User, ensure that authentication runs before rate limiting evaluates the endpoint policy.

A typical middleware sequence for a controller application is:
app.UseRouting();

app.UseAuthentication();
app.UseRateLimiter();
app.UseAuthorization();

app.MapControllers();


The exact ordering should match the application's routing, authentication, and authorization requirements. If authentication endpoints themselves need protection, consider a separate policy that can also protect anonymous requests.

Rate limiting does not replace authorization. A client that remains under its rate limit must still be prevented from accessing resources it is not permitted to use.

Test Rate-Limiting Behavior

Do not validate a rate limiter only by inspecting its configuration. Send enough requests to confirm that it accepts requests within the limit and rejects excess requests.

For a policy that allows five requests, a test can send requests sequentially and inspect their status codes. Keep the test isolated from other requests sharing the same partition, and account for the configured time window.

Verify that:

  • Requests within the limit succeed.
  • Requests beyond the limit receive HTTP 429.
  • The rejection response has the expected format.
  • Different users or tenants receive separate limits when intended.
  • Anonymous requests cannot bypass the policy by manipulating untrusted headers.
  • Cancellation and timeouts behave appropriately for queued requests.
  • The effective limit is correct when the application runs on multiple instances.

If the application uses a queue, test the queue behavior as well. Queuing can smooth short bursts, but it can also increase latency and consume resources. For APIs that should reject excess traffic immediately, a queue limit of zero is often the simpler choice.

Common Mistakes to Avoid

Several implementation mistakes can make rate limiting ineffective or unnecessarily disruptive:

  • Defining a policy but never attaching it to an endpoint.
  • Using one limit for every endpoint despite significant differences in cost.
  • Treating a concurrency limit as a requests-per-minute quota.
  • Trusting arbitrary forwarding headers for client IP addresses.
  • Using unstable or attacker-controlled partition keys.
  • Assuming in-memory limits are shared across application instances.
  • Returning HTTP 500 instead of HTTP 429 when a request is rate-limited.
  • Allowing clients to retry rejected requests immediately.
  • Relying on rate limiting instead of authentication and authorization.
  • Applying limits without testing legitimate burst traffic and expected usage.

The best configuration is one that protects the service without unnecessarily rejecting normal client activity.

Conclusion

Rate-limiting middleware for fixed-window, sliding-window, token-bucket, and concurrency-based rules is included into ASP.NET Core. These systems aid in regulating the frequency of requests and shielding application resources from overuse.

Apply the policy at the proper controller or route after first selecting one that corresponds to the endpoint's workload. Partition limitations using trusted client identities, test the policy in practical scenarios, and return HTTP 429 for denied requests.


For applications running across multiple instances, remember that the built-in limiter is process-local. If you need a strict shared quota, use an appropriate distributed or gateway-level solution.

Rate limiting works best as one part of an API protection strategy that also includes authentication, authorization, monitoring, and deliberate capacity planning.

HostForLIFE.eu ASP.NET Core 11.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 :: How to Use ASP.NET to Stream Big JSON Responses Core Without Buffering?

clock October 16, 2026 13:46 by author Peter

When an ASP.NET Core API returns a few records, it is simple to return JSON. When an endpoint must return hundreds of thousands of records, a huge export, or a collection with significant volumes of data, the scenario is different.

A typical solution delivers the response after loading the complete result into memory and serializing it into JSON. When several queries are made at once, this method may result in increased memory use, a delay in the initial response bytes, and strain on the garbage collector.

Streaming allows an application to send data progressively instead of building the complete response before transmission. However, streaming does not automatically solve every memory problem. If the application loads the entire database result into a list before writing the response, it may still consume substantial memory.

This article explains how to stream large JSON responses in ASP.NET Core, how to use System.Text.Json with IAsyncEnumerable<T>, and what to consider when streaming database results in production.

Why Buffering Large JSON Responses Causes Problems

Consider an API that returns a large collection of orders.

app.MapGet("/api/orders", async (OrdersDbContext db) =>
{
    var orders = await db.Orders
        .Select(order => new
        {
            order.Id,
            order.OrderNumber,
            order.Total
        })
        .ToListAsync();

    return Results.Ok(orders);
});


The call to ToListAsync() materializes the complete result in memory. The framework then serializes the collection into the HTTP response.

For a small collection, this is usually acceptable. For a large export, the application may need to hold many objects in memory while preparing the response.

Under heavy traffic, several requests can perform this work simultaneously. Memory usage can increase quickly, leading to garbage collection activity, longer response times, or even an out-of-memory failure.

Streaming can reduce the amount of data that must be held in memory at once. It can also allow the server to begin sending a response before the entire dataset has been processed.

Stream JSON with IAsyncEnumerable<T>

ASP.NET Core supports asynchronous streaming through IAsyncEnumerable<T>. When an endpoint returns an asynchronous sequence, the framework can serialize the items progressively using System.Text.Json, rather than requiring the endpoint to first create a complete list.

For example, consider this endpoint:
using System.Runtime.CompilerServices;

app.MapGet("/api/orders", async (
    OrdersDbContext db,
    CancellationToken cancellationToken) =>
{
    return db.Orders
        .OrderBy(order => order.Id)
        .Select(order => new OrderResponse(
            order.Id,
            order.OrderNumber,
            order.Total))
        .AsAsyncEnumerable();
});

public sealed record OrderResponse(
    int Id,
    string OrderNumber,
    decimal Total);


This example returns an asynchronous sequence instead of calling ToListAsync().

The framework can enumerate the sequence as it writes the JSON response. The database provider can also retrieve results incrementally, depending on its implementation and query behavior.

Notice that the query projects database entities into a smaller response model. This avoids returning every database column and keeps the API contract separate from the database schema.

The CancellationToken parameter is included in the endpoint signature, but this version does not explicitly pass it to the query. For cancellation-aware database execution, use the token in the query operation as shown in the next example.
Pass Cancellation Tokens to the Database Query

Streaming can keep a database query active for as long as the response is being consumed. If the client disconnects, continuing to retrieve records wastes resources.

Pass the request cancellation token to the database operation and use it while enumerating the results.
using System.Runtime.CompilerServices;

app.MapGet("/api/orders", (
    OrdersDbContext db,
    CancellationToken cancellationToken) =>
{
    return StreamOrders(
        db,
        cancellationToken);
});

static async IAsyncEnumerable<OrderResponse> StreamOrders(
    OrdersDbContext db,
    [EnumeratorCancellation]
    CancellationToken cancellationToken)
{
    await foreach (var order in db.Orders
        .OrderBy(order => order.Id)
        .Select(order => new OrderResponse(
            order.Id,
            order.OrderNumber,
            order.Total))
        .AsAsyncEnumerable()
        .WithCancellation(cancellationToken))
    {
        yield return order;
    }
}

public sealed record OrderResponse(
    int Id,
    string OrderNumber,
    decimal Total);


The EnumeratorCancellation attribute allows the cancellation token supplied when the asynchronous iterator is enumerated to be applied to the iterator's token.

The WithCancellation() call passes the cancellation token to the asynchronous enumerator. Whether the database operation stops immediately depends on the database provider and its cancellation support.

This example illustrates the streaming pattern, but production code should also consider database command timeouts, connection limits, and how long clients are allowed to consume an export.

Understand the JSON Response Format

When ASP.NET Core serializes an IAsyncEnumerable<T> as a JSON response, it normally produces a JSON array.

For example:
[
  {
    "id": 1,
    "orderNumber": "ORD-1001",
    "total": 125.50
  },
  {
    "id": 2,
    "orderNumber": "ORD-1002",
    "total": 240.00
  }
]


The response is still one JSON document. The difference is that the server can write the array progressively as it enumerates the items.

This is useful for clients that download the response as a stream. However, many clients still deserialize the complete JSON array into memory before making the records available to application code.

If clients need to process each record as it arrives, verify that their HTTP and JSON libraries support incremental parsing. Server-side streaming alone does not guarantee that the client processes records incrementally.

Also remember that a streamed JSON array must remain valid JSON. If an exception occurs after the server has already written part of the response, the server generally cannot replace the partial response with a normal error object or a different HTTP status code.

Stream a Custom JSON Response with Utf8JsonWriter

Sometimes an API needs more control over the JSON structure or the serialization process. Utf8JsonWriter allows the application to write JSON directly to the response body.

The following example writes an array of values progressively:
using System.Text.Json;

app.MapGet("/api/numbers", async (
    HttpContext context,
    CancellationToken cancellationToken) =>
{
    context.Response.ContentType = "application/json";

    await using var writer = new Utf8JsonWriter(
        context.Response.BodyWriter);

    writer.WriteStartArray();

    for (var i = 1; i <= 1000; i++)
    {
        cancellationToken.ThrowIfCancellationRequested();

        writer.WriteNumberValue(i);

        if (i % 100 == 0)
        {
            await writer.FlushAsync(cancellationToken);
        }
    }

    writer.WriteEndArray();

    await writer.FlushAsync(cancellationToken);
});


This endpoint produces a JSON array containing numbers from 1 through 1000.

The example uses HttpResponse.BodyWriter, which provides a PipeWriter for writing response data. Calling FlushAsync() makes buffered output available to the underlying response pipeline.

The periodic flushes demonstrate how an application can send data progressively. They do not guarantee that every group of 100 numbers arrives as a separate network packet. The server, operating system, network, and client can all affect when bytes are actually delivered.

For real-world exports, write data as it becomes available instead of generating an artificial sequence. Avoid flushing after every individual value because frequent flushes can add overhead.

Use Keyset Pagination for Very Large Exports

Streaming a database query does not eliminate the cost of executing that query. A long-running query can hold a connection for an extended period and may place substantial pressure on the database.

For very large exports, keyset pagination is another option. The application retrieves a limited number of records at a time, using a stable ordering key to find the next batch.

For example:
app.MapGet("/api/orders/export", async (
    OrdersDbContext db,
    CancellationToken cancellationToken) =>
{
    const int batchSize = 500;
    var lastId = 0;

    while (true)
    {
        var batch = await db.Orders
            .AsNoTracking()
            .Where(order => order.Id > lastId)
            .OrderBy(order => order.Id)
            .Select(order => new OrderResponse(
                order.Id,
                order.OrderNumber,
                order.Total))
            .Take(batchSize)
            .ToListAsync(cancellationToken);

        if (batch.Count == 0)
        {
            break;
        }

        foreach (var order in batch)
        {
            // Process the current batch.
        }

        lastId = batch[^1].Id;
    }

    return Results.Ok();
});


This example illustrates fetching records in batches. It does not yet stream the records to the client; the response is only returned after the loop completes.

To build a real streaming export, write each batch to the response as it is retrieved. You must also choose a valid output format and define how the response should behave if a later batch fails.

Keyset pagination works best when the ordering key is stable and indexed. If the dataset changes during the export, records can be added or modified while the request is running. Decide whether the export needs a consistent snapshot or can tolerate changes during processing.

  • Common Mistakes When Streaming JSON
  • Loading everything before returning the response

Calling ToListAsync() on the entire dataset before returning a response can defeat the main memory benefit of streaming.

Retrieve records incrementally or in manageable batches when the dataset is large.

Assuming streaming removes all buffering

The framework, database provider, server, proxy, or client may still buffer some data. Streaming reduces application-level buffering, but it does not guarantee that every layer operates without buffers.

Ignoring client disconnections

A client may cancel a request before the export finishes. Propagate cancellation tokens to database operations and asynchronous enumerators where supported.

Returning an error after the response has started

Once the response body has been partially written, changing the status code or returning a new JSON error object may no longer be possible.

Validate parameters and perform essential checks before starting the response. For long-running exports, consider a background export job that generates a file and exposes its completion status separately.

Holding database connections for too long
Direct database streaming may keep a connection occupied throughout the download. For slow clients, this can reduce the number of concurrent database operations your application can support.

Batching, background generation, and temporary export files may be more appropriate when datasets are extremely large or downloads take a long time.

When Should You Use Streaming?
Streaming is a good fit when clients need large datasets and the application can produce records incrementally. It can reduce peak memory usage and allow the response to begin earlier.
However, not every endpoint needs streaming. For small responses, ordinary JSON serialization is simpler and usually easier to maintain.

For large exports, compare three approaches:

  • A normal buffered response for small collections.
  • Direct streaming when records can be produced and consumed progressively.
  • A background export that generates a downloadable file for very large or long-running jobs.
  • Choose based on response size, database behavior, client requirements, and operational limits. Monitor memory usage, database connection utilization, request duration, and failure rates before and after introducing streaming.

Conclusion
Large JSON replies can be streamed by ASP.NET Core without needing the application to first build the complete response in memory. For supported JSON endpoints, returning an IAsyncEnumerable is a simple choice, while Utf8JsonWriter offers greater control over the response format. Use asynchronous query execution, pass cancelation tokens, project just the necessary fields, and think about keyset pagination when a single long-running query is inappropriate for database-backed APIs.

When the entire pipeline is built for incremental processing, streaming works well. To make sure that the solution truly lowers memory load and enhances answer delivery, examine the database, server, network, and client behavior collectively.

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



European ASP.NET Core 10.0 Hosting - HostForLIFE :: Why the Database Query is Quick Yet Your C# API is Slow

clock October 13, 2026 12:31 by author Peter

Have you ever faced this problem?
Your SQL query runs in 100 milliseconds, but your C# API takes 2 or 3 seconds to give a response.
You check the database and think :

"The database is fast. So why is my API slow?"

This is a common problem in ASP.NET Core applications.

The database is only one part of an API request. There are many other things happening between the time a user sends a request and the time they get a response.

In this article, we will understand some simple and common reasons why a C# API can be slow even when the database query is fast.
First, understand what happens in an API

Suppose we have this API :
[HttpGet("orders")]
public async Task<IActionResult> GetOrders()
{
var orders = await _context.Orders
.ToListAsync();
return Ok(orders);
}


When a user calls this API, many things can happen:


The database is only one step in this process.
So even if the database takes only 100 ms, another part of the application may be taking 2 seconds.

Let's look at some common reasons :

1. You are returning too much data
This is one of the easiest mistakes to make. Imagine your Customers table has 100,000 records.

You write:
var customers = await _context.Customers.ToListAsync();
return Ok(customers);

The database query might be reasonably fast.

But now your application has to:

  • Read all the records.
  • Create C# objects.
  • Convert those objects into JSON.
  • Send all that data to the client.
  • That can take a lot of time.

A better approach , Only get the data you actually need.
var customers = await _context.Customers.Select(x => new
{
x.Id,
x.Name,
x.Email
})
.ToListAsync();
return Ok(customers);


Now you are not sending unnecessary columns.

2. Your API may be making many database calls

Your first query might be fast.

But what if your code runs that query 100 times?

For example:
foreach (var order in orders)
{
var customer = await _context.Customers
.FirstOrDefaultAsync(x => x.Id == order.CustomerId);
}


If you have 100 orders, you could end up making 100 extra database calls.

Each call may be fast. But together, they can make your API slow.

This problem is commonly called the N+1 query problem.

The simple lesson is:

  • One fast query does not always mean a fast API.
  • Always check how many database queries your API is actually making.

3. Entity Framework Core also needs time
Many C# applications use Entity Framework Core to work with databases.

When you run:
var users = await _context.Users.ToListAsync();

the work does not stop when SQL Server returns the data.

Entity Framework Core also needs to:

  • Read the returned data.
  • Create C# objects.
  • Track those objects.
  • Prepare them for your application.
  • For read-only operations, you can sometimes use AsNoTracking():

var users = await _context.Users.AsNoTracking().ToListAsync();

This tells Entity Framework Core that you don't need to track these objects for changes.

For large read-only queries, this can reduce some unnecessary work.

4. JSON conversion can take time

Your API normally sends JSON to the client.

For example, your C# object:
new Customer
{
Id = 1,
Name = "John"
}

may become:

{
"id": 1,
"name": "John"
}


If you return thousands of records with many properties, converting all those objects to JSON can take time.

It also creates a larger response.

So the problem may not be the database.

It could be:

  • Database → Fast
  • C# processing → Slow
  • JSON conversion → Slow

Network → Slow

5. Another API may be slowing you down
Sometimes your API calls another service.

For example:

Your database is fast. But your complete API request is slow because it depends on other services.

This is very common in real-world applications.

6. Business logic can also take time
Suppose your API gets data from the database and then performs a lot of processing :

var orders = await GetOrders();

foreach (var order in orders)
{
// Complex calculations
// Price calculation
// Discount calculation
// Tax calculation
}

The database may take only 100 ms. But your C# code could take another 1 or 2 seconds.

This is why you should measure the complete API, not only the SQL query.

7. How do you find the real problem?
Don't guess. Measure the different parts of your API.

For example :
var stopwatch = Stopwatch.StartNew();
var orders = await _context.Orders
.AsNoTracking().ToListAsync();
stopwatch.Stop();
_logger.LogInformation(
"Database call took {Time} ms",
stopwatch.ElapsedMilliseconds);


Now you can see how long the database operation took from the application's point of view.

You can also measure other parts of your code.

For example:

Process

Time

Database

100 ms

Business Logic

900 ms

JSON Conversion

600 ms

External API

800 ms

Total Time

2400 ms (2.4 seconds)

Now you have a much better idea of where the problem is.

A simple real-world example :

  • Imagine an e-commerce application.
  • A customer opens the My Orders page.
  • The API takes 3 seconds.
  • The developer checks SQL Server:
  • SQL Query = 80 ms

They think:
"The database is not the problem." Then they check the API.

They discover :

They discover :

Process

Time

Database

80 ms

Customer Lookup

400 ms

Product Lookup

700 ms

JSON Conversion

600 ms

Other Processing

500 ms

Network

300 ms

Total Time

~2580 ms (2.58 seconds)

Now the problem is much clearer.

The SQL query itself was fast. The API was slow because of the other work happening around the query.

What should you check when your C# API is slow?

When debugging a slow API, check these things:

  • How many database queries are being executed?
  • Are you loading more data than necessary?
  • Are you using AsNoTracking() for read-only queries where appropriate?
  • Are you making calls to other APIs?
  • Is your C# business logic taking too much time?
  • Is JSON serialization taking too much time?
  • Is the response very large?
  • Is the network slow?
  • Are middleware or authentication steps taking time?

Final thoughts , A slow API does not always mean a slow database.

Your API is a complete process:

The database is only one part of that process. So the next time someone says:
"The SQL query is fast, but the API is slow."

Don't immediately optimize the SQL query.

First, measure the complete request and find where the time is actually being spent. Measure first. Find the slow part. Then optimize it.

Conclusion

A slow API is not always caused by the database. Identify performance bottlenecks, optimize queries, and avoid unnecessary processing to make your API faster and more efficient.

HostForLIFE.eu ASP.NET Core 11.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 :: Learning MediatR and CQRS in Clean Architecture for ASP.NET Core

clock October 7, 2026 14:26 by author Peter

Controllers frequently become victims of the "fat controller" anti-pattern as applications grow. They create an unmaintainable file with hundreds of lines of code that combines database queries, DTO mapping, business logic, and validation criteria. Additionally, even though reads typically need optimal projections and writes require stringent business rule validation, standard CRUD systems handle reads and writes in the same manner.

Modern corporate systems use Clean Architecture with MediatR and CQRS (Command Query Responsibility Segregation) to address this.

We will examine how CQRS and MediatR collaborate to decouple your application layers, maintain extremely thin controllers, and make your software incredibly scalable and testable in this thorough book.

What is CQRS and MediatR?
CQRS: Splits your data operations into two distinct categories:

  • Commands: Write operations that mutate state (Create, Update, Delete) and typically return nothing or an ID.
  • Queries: Read operations that fetch data without modifying state and return DTOs.

MediatR: An implementation of the Mediator pattern in .NET. It facilitates in-process messaging, decoupling your API controllers from your business logic handlers so that controllers simply act as thin dispatchers.

Step 1: Install MediatR in the Application Layer
Commands and queries belong in your Application layer because they represent business use cases.

Navigate to your YourSolution.Application project and install the core MediatR package:
dotnet add package MediatR

(Note: In modern versions of MediatR, dependency injection extensions are bundled directly into the core package).

Step 2: Organize by Feature Folders
Instead of grouping files by technical role (putting all services together or all classes together), organize your Application layer by features. This keeps everything related to a specific domain action localized:

YourSolution.Application/
├── Features/
│   â””── Products/
│       â”œâ”€â”€ Commands/
│       â”‚   â”œâ”€â”€ CreateProductCommand.cs
│       â”‚   â””── CreateProductCommandHandler.cs
│       â””── Queries/
│           â”œâ”€â”€ GetProductByIdQuery.cs
│           â””── GetProductByIdQueryHandler.cs
└── DependencyInjection.cs


Step 3: Implement a Command (Write Operation)

Commands represent actions that change state. We use C# record types for immutability, paired with a dedicated handler that executes the logic.

1. The Command Record

using MediatR;

namespace YourSolution.Application.Features.Products.Commands
{
    public record CreateProductCommand(string Name, decimal Price) : IRequest<Guid>;
}


2. The Command Handler
The handler contains the business rules and interacts with domain entities and repository interfaces.
using MediatR;
using YourSolution.Domain.Entities;
using YourSolution.Domain.Interfaces;

namespace YourSolution.Application.Features.Products.Commands
{
    public class CreateProductCommandHandler : IRequestHandler<CreateProductCommand, Guid>
    {
        private readonly IProductRepository _productRepository;

        public CreateProductCommandHandler(IProductRepository productRepository)
        {
            _productRepository = productRepository;
        }

        public async Task<Guid> Handle(CreateProductCommand request, CancellationToken cancellationToken)
        {
            // 1. Instantiate domain entity (encapsulating business rules)
            var product = new Product(request.Name, request.Price);

            // 2. Persist via repository interface
            await _productRepository.AddAsync(product);
            await _productRepository.UnitOfWork.SaveChangesAsync(cancellationToken);

            // 3. Return the generated ID
            return product.Id;
        }
    }
}


Step 4: Implement a Query (Read Operation)

Queries fetch data without mutating state and typically return lightweight DTOs.

1. The Query Record
using MediatR;
using YourSolution.Application.DTOs;

namespace YourSolution.Application.Features.Products.Queries
{
    public record GetProductByIdQuery(Guid Id) : IRequest<ProductDto>;
}


2. The Query Handler
using MediatR;
using YourSolution.Application.DTOs;
using YourSolution.Domain.Interfaces;

namespace YourSolution.Application.Features.Products.Queries
{
    public class GetProductByIdQueryHandler : IRequestHandler<GetProductByIdQuery, ProductDto>
    {
        private readonly IProductRepository _productRepository;

        public GetProductByIdQueryHandler(IProductRepository productRepository)
        {
            _productRepository = productRepository;
        }

        public async Task<ProductDto> Handle(GetProductByIdQuery request, CancellationToken cancellationToken)
        {
            var product = await _productRepository.GetByIdAsync(request.Id);
            
            if (product == null)
                throw new KeyNotFoundException($"Product with ID {request.Id} was not found.");

            // Map domain model to a lightweight DTO
            return new ProductDto(product.Id, product.Name, product.Price);
        }
    }
}

Step 5: Register MediatR in Dependency Injection
To make MediatR discover your command and query handlers automatically, add an extension method inside your Application layer:

using Microsoft.Extensions.DependencyInjection;

namespace YourSolution.Application
{
    public static class DependencyInjection
    {
        public static IServiceCollection AddApplication(this IServiceCollection services)
        {
            // Scans the assembly and registers all IRequestHandler implementations
            services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly));

            return services;
        }
    }
}


Ensure this is invoked in your API's Program.cs: builder.Services.AddApplication();.

Step 6: Keep API Controllers Thin
With CQRS and MediatR handling the heavy lifting, your API controllers become remarkably streamlined. They no longer contain business logic or database dependencies; instead, they simply inject IMediator and dispatch requests.
using MediatR;
using Microsoft.AspNetCore.Mvc;
using YourSolution.Application.Features.Products.Commands;
using YourSolution.Application.Features.Products.Queries;

namespace YourSolution.API.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class ProductsController : ControllerBase
    {
        private readonly IMediator _mediator;

        public ProductsController(IMediator mediator)
        {
            _mediator = mediator;
        }

        [HttpPost]
        public async Task<IActionResult> Create([FromBody] CreateProductCommand command)
        {
            var productId = await _mediator.Send(command);
            return CreatedAtAction(nameof(GetById), new { id = productId }, productId);
        }

        [HttpGet("{id}")]
        public async Task<IActionResult> GetById(Guid id)
        {
            var product = await _mediator.Send(new GetProductByIdQuery(id));
            return Ok(product);
        }
    }
}

Summary of Benefits
By combining Clean Architecture with CQRS and MediatR, you achieve:
Single Responsibility Principle: Every command and query handler has exactly one job and one reason to change.
Extreme Testability: You can unit test any command or query handler in isolation without spinning up an ASP.NET Core web server or database.

Scalability: Read models and write models can evolve independently as your application requirements grow.

HostForLIFE.eu ASP.NET Core 11.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 :: Redact Sensitive Data from AI Application Logs in .NET

clock October 5, 2026 13:48 by author Peter

Before invoking the logger, project each event into a specific set of allowed fields to prevent sensitive data from appearing in AI application logs. Instead of relying on a final text replacement to capture everything, do not feed raw prompts, nested request objects, response bodies, or error messages into regular operational logs.

An operation identification, a controlled route name, status, duration, and a fixed error category are all recorded in the C# sample that follows. It renders renders renders renders renders renders renders renders renders renders renders renders.

The goal is a compact, inspectable logging contract with practical diagnostic evidence. This sample addresses one logging border; it does not verify an application's whole telemetry infrastructure.

Why can structured logs still expose sensitive data?
Structured logging preserves named fields so tools can search and aggregate events. Those fields can contain sensitive values just as easily as a formatted message can.

Masking the visible message does not establish that the underlying state is safe. A sink can serialize structured values separately. An exception can carry sensitive text in its message, inner exception, or data dictionary. A nested payload can acquire a new field after the original redaction code was written. The OWASP logging guidance identifies data such as credentials and sensitive personal information as requiring exclusion or appropriate handling. The practical implication here is to decide which operational facts are needed before selecting what to record.

What should the event contain?

Start with questions the operations team needs to answer: which route failed, how long the attempt took, and which related events belong to the same operation.

Input

Logged form in this example

Reason

Application-generated operation ID

GUID

Correlate attempts without recording conversation text

Provider route

Controlled label

Group behavior without exposing a credential or URL

HTTP status

Validated number

Inspect response classes

Duration

Nonnegative milliseconds

Diagnose slow attempts

Exception

Fixed category

Identify a broad failure type without raw exception content

Prompt and nested payload

Constant marker

Prevent payload contents entering this event

The operation ID should be generated for the workflow, not copied from arbitrary user input. Correlation identifiers can still reveal activity patterns, so access and retention remain relevant even when content is omitted.

What environment does the example require?
The code was compiled with SDK 8.0.414 and C# 12 and executed on .NET 8.0.20 on Linux. It uses Microsoft.Extensions.Logging from the ASP.NET Core shared framework.
Use a .NET 8 console project with a FrameworkReference to Microsoft.AspNetCore.App. This complete project file supplies that reference:
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <LangVersion>12</LangVersion>
  </PropertyGroup>
  <ItemGroup>
    <FrameworkReference Include="Microsoft.AspNetCore.App" />
  </ItemGroup>
</Project>

Replace Program.cs with the C# code below and run the project. The framework reference makes the logging abstractions available; it does not turn this console example into a web server.

All email-like strings, credentials, and request values are synthetic. The example does not call an AI provider or send telemetry to an external logging service.
Create a narrow logging boundary
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging;

var sink = new CaptureLogger();
const string email = "[email protected]";
const string secret = "SECRET_47_SYNTHETIC";
var payload = new JsonObject
{
    ["prompt"] = $"Contact {email}",
    ["headers"] = new JsonObject { ["Authorization"] = secret },
    ["messages"] = new JsonArray(new JsonObject { ["content"] = secret })
};
var error = new HttpRequestException($"Failed with {secret}",
    new Exception($"Nested message: {email}"));
error.Data["raw_response"] = secret;
var attempt = new AiAttempt(Guid.NewGuid(), Route.Primary, 503, 120,
                            payload, error);
Audit.Write(sink, attempt);
payload["future_unknown_field"] = new JsonArray(secret, email);
Audit.Write(sink, attempt with { Status = 200, Error = null });
Audit.Write(sink, attempt with { Error = new OperationCanceledException(secret) });

foreach (var captured in sink.Events)
{
    string all = captured.Message + JsonSerializer.Serialize(captured.State)
                 + captured.Exception;
    if (all.Contains(secret) || all.Contains(email))
        throw new Exception("Sensitive sentinel reached a captured log surface");
    if (captured.Exception is not null)
        throw new Exception("Raw exception reached logger");
    string[] keys = captured.State.Keys.OrderBy(x => x).ToArray();
    string[] expected = ["{OriginalFormat}", "DurationMs", "ErrorKind",
        "OperationId", "Payload", "Route", "Status"];
    if (!keys.SequenceEqual(expected.OrderBy(x => x)))
        throw new Exception("Unexpected structured field");
    Console.WriteLine(captured.Message);
}
Console.WriteLine("PASS: 3 events; message, structured state, and exception checked");

enum Route { Primary, Backup }
record AiAttempt(Guid OperationId, Route Route, int Status, long DurationMs,
                 JsonNode Payload, Exception? Error);

static class Audit
{
    public static void Write(ILogger logger, AiAttempt a)
    {
        string route = a.Route switch
        {
            Route.Primary => "primary", Route.Backup => "backup", _ => "unknown"
        };
        string errorKind = a.Error switch
        {
            null => "none", OperationCanceledException => "canceled",
            HttpRequestException => "transport", _ => "internal"
        };
        int status = a.Status is >= 100 and <= 599 ? a.Status : 0;
        long duration = Math.Max(0, a.DurationMs);
        // Deliberately omit Payload and all exception content.
        logger.LogInformation(
            "AI attempt {OperationId} route={Route} status={Status} " +
            "duration_ms={DurationMs} error={ErrorKind} payload={Payload}",
            a.OperationId, route, status, duration, errorKind, "[REDACTED]");
    }
}

record Captured(string Message, Dictionary<string, object?> State,
                string? Exception);

sealed class CaptureLogger : ILogger
{
    public List<Captured> Events { get; } = [];
    public IDisposable? BeginScope<TState>(TState state) where TState : notnull
        => null;
    public bool IsEnabled(LogLevel level) => true;
    public void Log<TState>(LogLevel level, EventId eventId, TState state,
        Exception? exception, Func<TState, Exception?, string> formatter)
    {
        var fields = state is IEnumerable<KeyValuePair<string, object?>> values
            ? values.ToDictionary(x => x.Key, x => x.Value)
            : new Dictionary<string, object?>();
        Events.Add(new Captured(formatter(state, exception), fields,
                                exception?.ToString()));
    }
}


AiAttempt represents the broader application event. Audit.Write selects only the permitted operational fields. It receives the raw payload but never passes it to ILogger; the Payload logging field always contains the same marker. The exception category comes from a controlled type switch. Neither Message, InnerException, nor Data is serialized. The original exception is also omitted from the logging call, so a sink cannot receive it through that argument.

The custom logger captures both the formatted message and the structured state. This makes the test inspect the event's data, instead of checking only what happens to appear on a console.

Why omit a nested payload instead of walking every key?

A key-based redaction rule must recognize every sensitive location. A newly added messages, attachments, or diagnostic field can bypass an incomplete list. A projection that never includes the raw object has a smaller contract to inspect.

In the test, a new nested field is added after the first event. The later events still contain only the approved operational fields and the constant marker. The test does not depend on recognizing the new field's name. A technical walkthrough for Ranknod could use this distinction to show why redaction should be evaluated at the logging boundary. A clean-looking sample line alone does not establish that structured data and exception arguments are equally clean.

If selective content logging is necessary, design that as a separate, reviewed path with explicit purpose, access, and retention. Do not gradually expand an operational event into a transcript archive because each extra field is convenient.

What did the checks establish?

The executed sample captured three events: a transport failure, an event without an exception after the payload mutation, and a cancellation-category event. It checked each event for both sensitive sentinel strings, required the expected structured keys, and required the exception field to be null.

The final output was:
PASS: 3 events; message, structured state, and exception checked
The event messages retained status, duration, route, error category, and the redaction marker. The randomly generated operation GUID varies between runs.

These checks are intentionally scoped. They establish the behavior of Audit.Write with the supplied fixtures. They do not test background HTTP diagnostics, logging scopes, tracing spans, crash dumps, SDK diagnostics, or a production sink's enrichment rules.

How does this relate to .NET's redaction APIs?

Microsoft's .NET redaction documentation describes data classification, redactor configuration, and integration with logging. Those facilities can support applications that need classified fields and consistent redaction policies across many event types.

The example here uses explicit projection and a constant payload marker. It does not configure the compliance-redaction packages or claim that installing a package automatically classifies every field.

Choose the approach that fits the data contract, then inspect the actual emitted event. With either approach, an unclassified free-form field or a separate raw-exception call can create a path around the intended protection.

What should you check in the real application?

Exercise success, provider failure, timeout, cancellation, and retry paths with synthetic sentinels. Inspect every enabled sink, including structured exports. Add a new nested payload field and confirm that it does not enter an existing event unnoticed. Review scopes and automatic enrichers separately. This capture logger does not implement scope storage, so its success says nothing about a real application's scope contents. Also inspect SDK logging that occurs before your application wrapper receives the error.

Keep the fixed template and permitted-field list under review when the event changes. A useful operational log should explain what happened without copying the conversation that happened to trigger it.

HostForLIFE.eu ASP.NET Core 11.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