When a .NET API works perfectly in staging and then falls over under real production load, the root cause is almost never a single catastrophic bug. It is usually a collection of small inefficiencies — a blocking call here, an untracked entity graph there, a misconfigured middleware pipeline — that only become visible once concurrent request volume climbs into the thousands per minute. ASP.NET Core performance tuning is the practice of finding and removing those inefficiencies systematically, rather than reacting to outages after they happen. This post walks through the areas that matter most for high-traffic APIs: async correctness, caching, data access, serialization, middleware ordering, scaling, and the diagnostic tools that tell you where to actually spend your time.

Async/Await Done Correctly

The single most common performance killer in production ASP.NET Core applications is sync-over-async code — calling .Result or .Wait() on a task instead of awaiting it. This pattern blocks a thread pool thread while it waits for an I/O-bound operation to complete, and under load it triggers thread pool starvation: the pool has to grow slowly, new requests queue up waiting for a free thread, and latency spikes across the entire application even though CPU utilization looks low. The fix is straightforward in principle — await all the way up the call stack — but it requires discipline across an entire codebase, including in places developers do not expect, like static constructors, custom middleware, and background service loops.

[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
    private readonly IOrderRepository _orders;

    public OrdersController(IOrderRepository orders)
    {
        _orders = orders;
    }

    [HttpGet("{id:int}")]
    public async Task<ActionResult<OrderDto>> GetOrder(int id, CancellationToken cancellationToken)
    {
        var order = await _orders.GetByIdAsync(id, cancellationToken);
        if (order is null)
        {
            return NotFound();
        }

        return Ok(order.ToDto());
    }
}

Enter fullscreen mode Exit fullscreen mode

Note the CancellationToken parameter, which ASP.NET Core automatically wires up to the request's abort signal. Threading it through to your data access and downstream HTTP calls means that when a client disconnects or a request times out, work stops immediately instead of continuing to consume thread pool and database resources for a response nobody will receive. Also watch for async void methods outside of event handlers — exceptions thrown inside them cannot be caught by the caller and will crash the process, which is a particularly nasty failure mode to debug under load.

Response Caching and Output Caching Strategies

Not every request needs to hit your application logic and database. For endpoints that return the same data to many callers within a short window — product catalogs, configuration data, public content — output caching can eliminate the majority of backend work entirely.

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOutputCache(options =>
{
    options.AddPolicy("Catalog", policy =>
        policy.Expire(TimeSpan.FromSeconds(60))
              .Tag("catalog"));
});

var app = builder.Build();
app.UseOutputCache();

app.MapGet("/api/products", async (ICatalogService catalog) =>
{
    var products = await catalog.GetAllAsync();
    return Results.Ok(products);
})
.CacheOutput("Catalog");

app.Run();

Enter fullscreen mode Exit fullscreen mode

The tag-based invalidation shown above is important: when a product is updated, you can evict just the catalog tag rather than flushing the entire cache or waiting out a fixed expiry, which keeps data reasonably fresh without sacrificing hit rate. Response caching headers (Cache-Control, ETag) are also worth setting correctly even when you are not using server-side output caching, because they let browsers, mobile clients, and CDNs avoid the round trip altogether.

Minimal APIs vs Controllers: Overhead Considerations

Minimal APIs were introduced to reduce the ceremony and per-request overhead associated with the traditional MVC controller pipeline. Controllers go through model binding, filter execution, and action invocation machinery that, while flexible, does carry measurable overhead compared to a minimal API endpoint that maps a route directly to a delegate.

That said, the difference matters far less than people assume for typical business endpoints where a database call or downstream HTTP request dominates the total request time. A more pragmatic approach is to keep controllers where they provide value — model binding validation, consistent filter pipelines, Swagger integration, shared base classes — and use minimal APIs selectively for genuinely hot paths or new lightweight services where the reduced ceremony is a net win. The architectural decision should be driven by actual measured bottlenecks, not by a general belief that one style is always faster.

Connection Pooling and Database Query Optimization

Database access is where most high-traffic ASP.NET Core APIs actually spend their time. Two problems show up again and again: unnecessary change tracking on read-only queries, and the N+1 query pattern where a single logical request quietly issues dozens or hundreds of round trips to the database.

Entity Framework Core tracks entities by default so it can detect and persist changes back to the database. For read-only queries — the majority of traffic on most APIs — that tracking overhead is pure waste. Calling AsNoTracking() tells EF Core to skip building the change-tracking snapshot.

public async Task<List<OrderSummaryDto>> GetRecentOrdersAsync(
    int customerId, CancellationToken cancellationToken)
{
    return await _dbContext.Orders
        .AsNoTracking()
        .Where(o => o.CustomerId == customerId)
        .OrderByDescending(o => o.CreatedAt)
        .Take(20)
        .Select(o => new OrderSummaryDto
        {
            Id = o.Id,
            Total = o.Total,
            Status = o.Status
        })
        .ToListAsync(cancellationToken);
}

Enter fullscreen mode Exit fullscreen mode

Projecting directly into a DTO with Select(), as shown above, has an additional benefit beyond avoiding tracking overhead: EF Core can translate the projection into a SQL query that only selects the columns you actually need, rather than pulling back entire entity rows. The N+1 problem is subtler and often invisible in local testing with small datasets — it happens when code loads a collection of parent entities and then, for each one, lazily triggers a separate query to load related child data. The fix is to use Include() to eagerly load related data in a single query, or better yet, to project only the specific fields you need.

On the connection pooling side, make sure your connection string and DbContext lifetime are configured sensibly for your workload. AddDbContextPool reuses DbContext instances across requests rather than allocating a new one each time, which reduces allocation pressure under high request volume.

Response Compression

For APIs returning JSON payloads of any meaningful size, enabling response compression reduces the bytes sent over the wire. ASP.NET Core's response compression middleware supports both Gzip and Brotli, and Brotli generally produces smaller payloads at a comparable or better compression speed for typical JSON content.

builder.Services.AddResponseCompression(options =>
{
    options.EnableForHttps = true;
    options.Providers.Add<BrotliCompressionProvider>();
    options.Providers.Add<GzipCompressionProvider>();
});

builder.Services.Configure<BrotliCompressionProviderOptions>(options =>
{
    options.Level = System.IO.Compression.CompressionLevel.Fastest;
});

Enter fullscreen mode Exit fullscreen mode

The compression level matters: the higher compression levels squeeze out more bytes but cost more CPU per request, which can become a bottleneck of its own under high concurrency. If you are already terminating TLS and compressing at a reverse proxy or CDN layer, double check you are not compressing twice.

Efficient JSON Serialization

Serialization happens on essentially every request an API handles, which makes it a disproportionately important target for tuning. System.Text.Json, the default serializer in ASP.NET Core, is fast out of the box, but its default configuration is not always tuned for a specific application's payload shapes.

builder.Services.Configure<Microsoft.AspNetCore.Http.Json.JsonOptions>(options =>
{
    options.SerializerOptions.DefaultIgnoreCondition =
        System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull;
    options.SerializerOptions.PropertyNamingPolicy =
        System.Text.Json.JsonNamingPolicy.CamelCase;
    options.SerializerOptions.NumberHandling =
        System.Text.Json.Serialization.JsonNumberHandling.AllowReadingFromString;
});

Enter fullscreen mode Exit fullscreen mode

Omitting null properties, as configured above, both shrinks payload size and reduces serialization work for objects with many optional fields. For very high-throughput scenarios, consider source-generated serialization via JsonSerializerContext, which produces serialization code at compile time instead of relying on runtime reflection. It is also worth auditing your DTOs directly rather than serializing full EF Core entities: entities often carry navigation properties and extra fields that bloat the payload and can accidentally trigger lazy-load queries mid-serialization.

Middleware Pipeline Ordering and Its Performance Impact

Every request in ASP.NET Core passes through the middleware pipeline in the order it was registered, and that ordering has real performance consequences, not just correctness implications. Middleware registered early in the pipeline runs on every single request, including ones that will ultimately be rejected or short-circuited further down, so expensive middleware placed too early wastes work on requests that never needed it. Authentication and authorization middleware should generally run before expensive business-logic middleware, so unauthenticated requests are rejected cheaply and early. Output caching should typically sit early enough to short-circuit the pipeline entirely on a cache hit.

Health Checks and Load Balancer Considerations

In a horizontally scaled deployment, your load balancer or orchestrator relies on health check endpoints to decide whether an instance should keep receiving traffic.

builder.Services.AddHealthChecks()
    .AddDbContextCheck<AppDbContext>("database")
    .AddCheck<DependencyHealthCheck>("downstream-api");

app.MapHealthChecks("/health/live", new HealthCheckOptions
{
    Predicate = check => check.Tags.Contains("live")
});

app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
    Predicate = _ => true
});

Enter fullscreen mode Exit fullscreen mode

Separating liveness from readiness checks, as shown above, matters at scale: a liveness check should be extremely cheap and simply confirm the process is running and responsive, since it may be polled every few seconds by an orchestrator deciding whether to restart the instance. A readiness check can afford to verify downstream dependencies, but if that check itself is slow, it can become a load source in its own right when polled frequently across many instances.

Horizontal Scaling and Stateless Design

No amount of in-process tuning replaces the ability to add more instances when traffic grows, but horizontal scaling only works cleanly if your application is actually stateless. Session state, in-memory caches that are not synchronized across instances, and singleton services holding request-specific mutable state all break the assumption that any instance can handle any request. Before scaling out, audit for these patterns: session data should live in a distributed store like Redis rather than in-process memory, and background work that must run exactly once should use a distributed lock or a dedicated worker rather than relying on in-process timers that would fire redundantly on every instance.

Profiling and Diagnostics: Finding Bottlenecks Instead of Guessing

Every technique described above is only useful when applied to an actual measured bottleneck. dotnet-trace is the first tool worth reaching for when you need to understand what a running ASP.NET Core process is actually doing under load — it captures a low-overhead event trace and is particularly good at surfacing thread pool starvation, garbage collection pressure, and time spent in specific method calls. Application Insights, or an equivalent APM tool, fills a different and complementary role: continuous, low-overhead monitoring across the full request lifecycle, including dependency calls to your database and downstream services. For micro-level questions — is this specific method faster with AsNoTracking(), does this serialization configuration actually reduce allocations — BenchmarkDotNet is the right tool, since it handles the notoriously tricky mechanics of accurate .NET benchmarking correctly: JIT warmup, garbage collection isolation between iterations, and statistical reporting that distinguishes a real difference from noise.

Bringing ASP.NET Core Performance Tuning Together

None of these techniques exist in isolation, and applying all of them at once to a codebase that has not been profiled is not a good strategy. The right approach is iterative: measure with real diagnostic tools under representative load, identify the actual bottleneck — which is disproportionately likely to be database access or a sync-over-async pattern rather than JSON serialization or middleware ordering — fix that specific thing, and measure again. Caching, compression, and minimal API adoption are real levers, but they are most valuable once the fundamentals of async correctness and efficient data access are already in place; layering caching on top of an N+1 query problem just means you are caching a slow response instead of fixing it.

If your team is under pressure to scale an existing API and does not have deep in-house experience with these patterns, it is often faster and less risky to bring in an experienced ASP.NET Core developer for a focused performance-tuning engagement rather than learning through production incidents.